How to Build a Lightweight Framework from Scratch (Routing, State, DOM)
#webdev
#javascript
#microframework
#routing
#dom
Overview
This post walks through building a minimal, self-contained framework from scratch focused on three core areas: routing, state management, and DOM rendering. The goal is to keep the API tiny, predictable, and dependency-free while still being useful for small apps or learning experiments.
Design goals
- Small footprint: a few hundred lines of code, easy to inspect.
- Decoupled concerns: routing, state, and DOM rendering are modular.
- Imperative DOM updates: no virtual DOM, direct DOM manipulation for clarity.
- Simple API: intuitive methods for navigation, state updates, and rendering.
Architecture at a glance
- Router: handles URL changes, history API, and view mounting.
- Store: a tiny publish/subscribe state container with shallow merges.
- DOM renderer: a tiny helper to create DOM nodes and compose views.
- App: a minimal example that wires routing and state together.
Routing: A tiny router
The router maps paths to view-rendering functions and updates the DOM when navigation occurs. It uses the History API and the popstate event to respond to back/forward actions.
// Minimal Router
class Router {
constructor(routes, root) {
this.routes = routes;
this.root = root;
}
start() {
window.addEventListener('popstate', () => this._render());
this._render();
}
navigate(path) {
window.history.pushState(null, '', path);
this._render();
}
_render() {
const path = window.location.pathname;
const route = this.routes.find(r => r.path === path) || this.routes.find(r => r.path === '*');
this.root.innerHTML = '';
if (route && typeof route.view === 'function') {
this.root.appendChild(route.view());
} else {
this.root.appendChild(document.createTextNode('Not Found'));
}
}
}
State management: A tiny store
The store is a lightweight pub/sub container. It holds a state object, allows updates via setState, and notifies subscribers on changes. This keeps components loosely coupled.
// Tiny Store
class Store {
constructor(initial = {}) {
this.state = initial;
this.listeners = [];
}
getState() {
return this.state;
}
setState(partial) {
this.state = { ...this.state, ...partial };
this._notify(this.state);
}
subscribe(listener) {
this.listeners.push(listener);
return () => this.unsubscribe(listener);
}
unsubscribe(fn) {
this.listeners = this.listeners.filter(l => l !== fn);
}
_notify(state) {
this.listeners.forEach(fn => fn(state));
}
}
DOM rendering: a tiny helper
A small helper keeps DOM creation ergonomic and readable without pulling in a heavy library.
// Tiny hyperscript-like helper
function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
if (k === 'onclick' && typeof v === 'function') {
el.addEventListener('click', v);
} else if (k.startsWith('data-') || k.startsWith('aria-')) {
el.setAttribute(k, v);
} else {
el.setAttribute(k, v);
}
});
children.forEach(child => {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else if (child) {
el.appendChild(child);
}
});
return el;
}
Putting it together: a minimal app
This single app demonstrates routing between a home view and a counter view that shares state via the store.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Minimal Framework Demo</title>
</head>
<body>
<div id="app"></div>
<script>
// Import/define the pieces (paste the snippets above in a real setup)
// For this self-contained demo, we re-declare briefly here:
// Tiny DOM helper (redeclare for completeness)
function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
if (k === 'onclick' && typeof v === 'function') {
el.addEventListener('click', v);
} else {
el.setAttribute(k, v);
}
});
children.forEach(child => {
if (typeof child === 'string') el.appendChild(document.createTextNode(child));
else if (child) el.appendChild(child);
});
return el;
}
// Tiny Store
class Store {
constructor(initial = {}) {
this.state = initial;
this.listeners = [];
}
getState() { return this.state; }
setState(partial) {
this.state = { ...this.state, ...partial };
this._notify(this.state);
}
subscribe(fn) { this.listeners.push(fn); return () => this.unsubscribe(fn); }
unsubscribe(fn) { this.listeners = this.listeners.filter(l => l !== fn); }
_notify(state) { this.listeners.forEach(fn => fn(state)); }
}
// Tiny Router
class Router {
constructor(routes, root) {
this.routes = routes;
this.root = root;
}
start() {
window.addEventListener('popstate', () => this._render());
this._render();
}
navigate(path) {
window.history.pushState(null, '', path);
this._render();
}
_render() {
const path = window.location.pathname;
const route = this.routes.find(r => r.path === path) || this.routes.find(r => r.path === '*');
this.root.innerHTML = '';
if (route && typeof route.view === 'function') {
this.root.appendChild(route.view());
} else {
this.root.appendChild(document.createTextNode('Not Found'));
}
}
}
// Views
// Home view
function HomeView() {
const container = document.createElement('div');
container.appendChild(h('h2', {}, 'Home'));
const btn = h('button', {}, 'Go to Counter');
btn.addEventListener('click', () => router.navigate('/counter'));
container.appendChild(btn);
const p = h('p', {}, 'This is a tiny example demonstrating routing, shared state, and DOM rendering without a framework.');
container.appendChild(p);
return container;
}
// Counter view
function CounterView() {
const container = document.createElement('div');
const state = store.getState();
const countEl = h('div', {}, `Count: ${state.count}`);
const incBtn = h('button', {}, 'Increment');
incBtn.addEventListener('click', () => {
const s = store.getState();
store.setState({ count: s.count + 1 });
});
container.appendChild(countEl);
container.appendChild(incBtn);
// Reactive update
store.subscribe((newState) => {
countEl.textContent = `Count: ${newState.count}`;
});
return container;
}
// Initialize app
const root = document.getElementById('app');
const store = new Store({ count: 0 });
const routes = [
{ path: '/', view: HomeView },
{ path: '/counter', view: CounterView },
];
// Create router and start
const router = new Router(routes, root);
router.start();
</script>
</body>
</html>
Note: In a real project, you would split the modules into separate files and bundle them, but this single-file demo illustrates how routing, state, and DOM rendering can coexist in a tiny framework.
Running the example locally
- Create an index.html with the code above.
- Open in a browser.
- Navigate between views via the button on the Home screen or by entering /counter in the address bar.
- Observe the shared state (count) persisting as you navigate.
Performance considerations
- Minimal DOM updates: only the affected sections are updated, not the whole page.
- Avoid frequent global re-renders; prefer targeted updates via subscriptions.
- Keep the store immutable-ish for predictable state changes, but don’t overcomplicate with deep cloning in a tiny app.
- This approach trades off features and tooling for clarity and small footprint; for larger apps, consider incremental enhancements or a small framework of your own design.
Final notes
Starting from scratch with a tiny router, a small state container, and a straightforward DOM rendering helper can be a powerful learning exercise. It clarifies the responsibilities of routing, state, and rendering, and it gives you a baseline you can extend with more features as needed.