How to Build a Lightweight Framework from Scratch (Routing, State, DOM)

Team 6 min read

#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.