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
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>
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);
}
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
}
});
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();
}
}
Usage:
import { Router } from './router.js';
const router = new Router({
'/': () => renderHome(),
'/todos': () => renderTodosPage(),
'/404': () => renderNotFound()
});
router.init();
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);
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 };
}
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 });
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();
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
Top comments (0)