DEV Community

Cover image for You Might Not Need a Framework: Building Modern Web Apps with Vanilla JavaScript
Abanoub Kerols
Abanoub Kerols

Posted on

You Might Not Need a Framework: Building Modern Web Apps with Vanilla JavaScript

In today's frontend world, it's easy to reach for React, Vue, or Svelte the moment a project needs interactivity, state management, or routing. But the modern browser has evolved dramatically. With native DOM APIs, the History API, Web Components, and powerful language features like Proxies, you can build rich, performant single-page applications (SPAs) using nothing but Vanilla JavaScript.

This approach gives you:

  • Smaller bundle sizes
  • Better performance (no virtual DOM overhead)
  • Full control and understanding of your code
  • No framework lock-in or version churn

Let's explore how to create a lightweight "Vanilla JS Framework" using five key pillars:

  • Vanilla JavaScript
  • The DOM
  • Routing
  • Web Components
  • Reactive Programming with Proxies

We'll build toward a simple todo-style SPA that demonstrates all of them working together.

1. Vanilla JavaScript: The Foundation
Start with plain, modern ES6+ JavaScript—no transpilers, no build tools required for small-to-medium apps (though you can add Vite later if you want).
Use native modules (import/export), arrow functions, template literals, destructuring, and async/await. Organize your code into modules for clarity.

Example project structure:

my-vanilla-app/
├── index.html
├── js/
│   ├── main.js
│   ├── router.js
│   ├── store.js          // reactive state
│   └── components/
│       └── todo-item.js
└── styles.css
Enter fullscreen mode Exit fullscreen mode

In index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Vanilla Todo</title>
  <script type="module" src="js/main.js"></script>
</head>
<body>
  <div id="app"></div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

This keeps everything lightweight and browser-native.

2. The DOM: Direct Manipulation Done Right
Forget innerHTML spam. Use modern DOM methods for efficiency and security.
Key techniques:

  • document.createElement(), append(), replaceChildren()
  • querySelector / closest for traversal
  • Event delegation instead of attaching listeners to every element
  • classList, dataset, and textContent for updates

Example: Rendering a list efficiently

// js/main.js
function renderTodos(todos, container) {
  container.replaceChildren(); // Clear efficiently

  const fragment = document.createDocumentFragment();

  todos.forEach(todo => {
    const el = document.createElement('todo-item');
    el.setAttribute('data-id', todo.id);
    el.todo = todo; // Custom property for the component
    fragment.append(el);
  });

  container.append(fragment);
}
Enter fullscreen mode Exit fullscreen mode

Event delegation example:

document.getElementById('app').addEventListener('click', e => {
  if (e.target.matches('.delete-btn')) {
    const id = e.target.closest('todo-item').dataset.id;
    // handle delete
  }
});
Enter fullscreen mode Exit fullscreen mode

This is fast, memory-efficient, and avoids common pitfalls of frameworks.

3. Routing: Client-Side Navigation Without Libraries

Modern browsers give you the History API (pushState, replaceState, popstate event) for clean URLs—no hash routing needed.

Simple Router Implementation:

// js/router.js
export class Router {
  constructor(routes) {
    this.routes = routes;
    this.currentRoute = null;

    window.addEventListener('popstate', () => this.handleRoute());
    document.addEventListener('click', e => {
      if (e.target.matches('a[data-link]')) {
        e.preventDefault();
        this.navigate(e.target.getAttribute('href'));
      }
    });
  }

  navigate(path) {
    history.pushState({}, '', path);
    this.handleRoute();
  }

  handleRoute() {
    const path = window.location.pathname || '/';
    const route = this.routes[path] || this.routes['/404'];

    if (route) {
      route(); // Render the view function
    }
  }

  init() {
    this.handleRoute();
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

import { Router } from './router.js';

const router = new Router({
  '/': () => renderHome(),
  '/todos': () => renderTodosPage(),
  '/404': () => renderNotFound()
});

router.init();
Enter fullscreen mode Exit fullscreen mode

This gives you bookmarkable, shareable URLs with zero dependencies.

4. Web Components: Reusable, Encapsulated UI Elements

Web Components (Custom Elements + Shadow DOM + HTML Templates) are native browser features that let you create reusable components with style and markup isolation.
Creating a Todo Item Component:

// js/components/todo-item.js
class TodoItem extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  set todo(data) {
    this._todo = data;
    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; padding: 8px; border-bottom: 1px solid #eee; }
        .done { text-decoration: line-through; opacity: 0.6; }
      </style>
      <label>
        <input type="checkbox" ${this._todo.done ? 'checked' : ''}>
        <span class="${this._todo.done ? 'done' : ''}">${this._todo.text}</span>
      </label>
      <button class="delete">×</button>
    `;

    // Add event listeners inside shadow DOM
    this.shadowRoot.querySelector('input').addEventListener('change', () => {
      this.dispatchEvent(new CustomEvent('toggle', { 
        detail: { id: this._todo.id },
        bubbles: true 
      }));
    });
  }

  connectedCallback() {
    this.render();
  }
}

customElements.define('todo-item', TodoItem);
Enter fullscreen mode Exit fullscreen mode

Now you can use it like native HTML: . Shadow DOM prevents style leakage, and connectedCallback / disconnectedCallback give you lifecycle hooks ⁠Mellowdevs.medium.

5. Reactive Programming with Proxies: Automatic UI Updates
JavaScript's Proxy API lets you intercept property access and mutations—perfect for building lightweight reactivity without signals or virtual DOM.

Simple Reactive Store:

// js/store.js
export function createStore(initialState) {
  const listeners = new Set();

  const handler = {
    set(target, prop, value) {
      target[prop] = value;
      listeners.forEach(listener => listener(target));
      return true;
    }
  };

  const state = new Proxy({ ...initialState }, handler);

  function subscribe(callback) {
    listeners.add(callback);
    callback(state); // Initial call
    return () => listeners.delete(callback);
  }

  return { state, subscribe };
}
Enter fullscreen mode Exit fullscreen mode

Usage in your app:

import { createStore } from './store.js';

const { state, subscribe } = createStore({
  todos: [],
  filter: 'all'
});

// React to changes
subscribe((newState) => {
  renderTodos(newState.todos, document.getElementById('todo-list'));
});

// Update state normally
state.todos.push({ id: Date.now(), text: 'Learn Vanilla JS', done: false });
Enter fullscreen mode Exit fullscreen mode

For finer control, you can use Reflect with more advanced traps, or dispatch custom events from the store. This pattern powers the reactivity in many "zero-dependency" libraries and mimics the core idea behind Vue's reactivity or Solid's signals Medium.

Putting It All Together
In main.js, wire everything up:

import { Router } from './router.js';
import { createStore } from './store.js';
import './components/todo-item.js';

const { state, subscribe } = createStore({ todos: [] });

const router = new Router({
  '/': () => renderDashboard(state),
  '/todos': () => renderTodoApp(state, subscribe)
});

router.init();
Enter fullscreen mode Exit fullscreen mode

Add form handlers, localStorage persistence, and you're done—a fully functional SPA with routing, components, and reactivity, all in Vanilla JS.

When Should You Still Use a Framework?

Vanilla JS shines for:

  • Small-to-medium apps
  • Performance-critical interfaces
  • Learning projects
  • Sites where bundle size and simplicity matter

Frameworks still win for very large teams, complex ecosystems (TypeScript + huge component libraries), or when you need battle-tested solutions for accessibility, server-side rendering, etc.
But for many projects in 2025–2026, the browser is more than capable. You might not need a framework after all

Recently built a small SPA using Vanilla JavaScript

I wanted to step away from frameworks for a bit and focus on the fundamentals, so I built this project using only native browser APIs.

💡 What I implemented:

Custom client-side routing (SPA behavior)
Dynamic DOM rendering & event handling
Component-based structure using Web Components
State management without external libraries
Clean separation of concerns and scalable structure

A full SPA using Vanilla JavaScript no frameworks

Top comments (0)