frontend intermediate

Modular Vanilla JS That Outperforms Frameworks

10 min read Frederick Tubiermont

Modular Vanilla JS That Outperforms Frameworks

There is a running joke in web development: every few years a new framework arrives promising to solve the complexity created by the last framework. By 2026, the irony is hard to miss. Svelte compiles down to vanilla JS for speed. Qwik ships as little JS as possible. Astro defaults to zero JS. They are all racing back toward the platform.

So why not start there with a proper architecture?

This tutorial builds a full modular vanilla JS system — reactive state, reusable components, a client-side router, lazy loading — and explains exactly how each piece replaces what a framework would give you. No build tools required beyond what you already have in the browser.

What You Will Build

A modular JS architecture with four layers:

  • state.js — reactive store powered by Proxy
  • components/ — self-contained UI modules with lifecycle hooks
  • router.js — History API client-side routing with lazy-loaded routes
  • main.js — clean composition root that wires everything together

You can drop this pattern into any Flask project and serve the JS as static files — no webpack, no npm, no node_modules.

Why Not Just Reach for React?

Concern Vanilla JS + AI React / Vue / Angular
Bundle size 5–20 KB 40–200 KB+
Lighthouse score 100/100 reachable Framework overhead caps you
Learning surface JavaScript itself JS + framework abstractions
Debugging Browser DevTools directly Framework DevTools extension needed
Longevity Web standards, forever Framework churn (Angular 1→2, etc.)
AI assistance Works with any AI tool Needs framework-aware context

The honest case for frameworks is large teams needing enforced patterns, or apps with hundreds of stateful components. For a Flask app served to real users, the overhead rarely pays for itself.

Prerequisites

  • Comfortable with ES6+ JavaScript (arrow functions, destructuring, async/await)
  • Basic understanding of the DOM API
  • A Flask project serving static files (or any web server)

No npm, no bundler, no transpiler.


1. The Reactive Store

The foundation is a Store class that wraps state in a Proxy. Any assignment to store.state.someKey automatically notifies subscribers.

// static/js/state.js

export class Store {
    #state;
    #listeners = new Map();

    constructor(initialState = {}) {
        this.#state = new Proxy(initialState, {
            set: (target, property, value) => {
                const old = target[property];
                target[property] = value;
                if (old !== value) {
                    this.#notify(property, value, old);
                }
                return true;
            }
        });
    }

    get state() {
        return this.#state;
    }

    subscribe(key, callback) {
        if (!this.#listeners.has(key)) {
            this.#listeners.set(key, []);
        }
        this.#listeners.get(key).push(callback);

        // Return an unsubscribe function — critical for cleanup
        return () => {
            const callbacks = this.#listeners.get(key) ?? [];
            this.#listeners.set(key, callbacks.filter(cb => cb !== callback));
        };
    }

    #notify(key, value, old) {
        this.#listeners.get(key)?.forEach(cb => cb(value, old));
    }
}

Key decisions:

  • Private fields (#state, #listeners) prevent accidental external mutation.
  • subscribe() returns an unsubscribe function. Always store it and call it in destroy() to avoid memory leaks.
  • The old !== value guard prevents unnecessary re-renders when the value did not change.

Usage:

import { Store } from './state.js';

const store = new Store({ count: 0, user: null });

const unsub = store.subscribe('count', (newVal, oldVal) => {
    console.log(`Count changed from ${oldVal} to ${newVal}`);
});

store.state.count = 5; // logs: "Count changed from 0 to 5"

// Later, when component is removed:
unsub();

2. The Component Pattern

Each component is a class with three responsibilities: render, attach events, destroy. This mirrors what React does internally — but you own it entirely.

// static/js/components/Counter.js

export class Counter {
    #store;
    #unsubs = [];
    element;

    constructor(store) {
        this.#store = store;
        this.element = this.#render();
        this.#attachEvents();
        this.#subscribe();
    }

    #render() {
        const el = document.createElement('div');
        el.className = 'counter';
        el.innerHTML = `
            <p class="counter__value">
                Count: <span data-bind="count">${this.#store.state.count}</span>
            </p>
            <div class="counter__controls">
                <button data-action="decrement">−</button>
                <button data-action="increment">+</button>
                <button data-action="reset">Reset</button>
            </div>
        `;
        return el;
    }

    #attachEvents() {
        this.element.addEventListener('click', (e) => {
            const action = e.target.dataset.action;
            if (action === 'increment') this.#store.state.count++;
            if (action === 'decrement') this.#store.state.count--;
            if (action === 'reset')     this.#store.state.count = 0;
        });
    }

    #subscribe() {
        const unsub = this.#store.subscribe('count', (value) => {
            const span = this.element.querySelector('[data-bind="count"]');
            if (span) span.textContent = value;
        });
        this.#unsubs.push(unsub);
    }

    destroy() {
        this.#unsubs.forEach(fn => fn());
        this.element.remove();
    }
}

Why event delegation? One listener on the container instead of one per button. The component can be torn down cleanly without hunting for individual listeners.

The destroy() contract: every component must implement destroy(). The router (next section) calls it automatically when navigating away, preventing memory leaks.


3. The Client-Side Router

The router uses the History API (pushState / popstate) to handle navigation without page reloads. Routes are registered as async functions that return a component instance — enabling lazy loading for free.

// static/js/router.js

export class Router {
    #routes = new Map();
    #current = null;
    #outlet;

    constructor(outletSelector) {
        this.#outlet = document.querySelector(outletSelector);
        if (!this.#outlet) throw new Error(`Router: outlet "${outletSelector}" not found`);

        window.addEventListener('popstate', () => this.#resolve(location.pathname));

        // Intercept all clicks on internal links
        document.addEventListener('click', (e) => {
            const link = e.target.closest('a[href]');
            if (!link) return;
            const href = link.getAttribute('href');
            if (href.startsWith('/') && !href.startsWith('//')) {
                e.preventDefault();
                this.navigate(href);
            }
        });
    }

    register(path, loader) {
        // loader is an async function that returns a component instance
        this.#routes.set(path, loader);
        return this; // chainable
    }

    async navigate(path) {
        if (path === location.pathname) return;
        history.pushState({}, '', path);
        await this.#resolve(path);
    }

    async #resolve(path) {
        const loader = this.#routes.get(path) ?? this.#routes.get('*');
        if (!loader) return;

        // Destroy previous component
        this.#current?.destroy();
        this.#outlet.innerHTML = '';

        const component = await loader();
        this.#current = component;
        this.#outlet.appendChild(component.element);
    }
}

Register routes like this:

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

const router = new Router('#app');

router
    .register('/', async () => {
        const { HomePage } = await import('./pages/HomePage.js');
        return new HomePage(store);
    })
    .register('/about', async () => {
        const { AboutPage } = await import('./pages/AboutPage.js');
        return new AboutPage(store);
    })
    .register('/dashboard', async () => {
        const { Dashboard } = await import('./pages/Dashboard.js');
        return new Dashboard(store);
    })
    .register('*', async () => {
        const { NotFound } = await import('./pages/NotFound.js');
        return new NotFound();
    });

// Handle first load
router.navigate(location.pathname);

Lazy loading is the default. Each import() is a dynamic import — the browser only downloads a page's JS when the user navigates to it. No webpack code splitting required.


4. Fetching Data — The Service Layer

Keep HTTP calls out of components. A service module centralises API interaction and handles errors consistently.

// static/js/services/api.js

const BASE = '/api';

async function request(path, options = {}) {
    const res = await fetch(`${BASE}${path}`, {
        headers: { 'Content-Type': 'application/json', ...options.headers },
        ...options,
    });

    if (!res.ok) {
        const error = await res.json().catch(() => ({ message: res.statusText }));
        throw new Error(error.message ?? `HTTP ${res.status}`);
    }

    return res.json();
}

export const api = {
    get:    (path)         => request(path),
    post:   (path, body)   => request(path, { method: 'POST',   body: JSON.stringify(body) }),
    put:    (path, body)   => request(path, { method: 'PUT',    body: JSON.stringify(body) }),
    delete: (path)         => request(path, { method: 'DELETE' }),
};

A component that fetches data:

// static/js/pages/Dashboard.js
import { api } from '../services/api.js';

export class Dashboard {
    #store;
    #unsubs = [];
    element;

    constructor(store) {
        this.#store = store;
        this.element = document.createElement('div');
        this.element.className = 'dashboard';
        this.#load();
    }

    async #load() {
        this.element.innerHTML = '<p class="loading">Loading…</p>';
        try {
            const data = await api.get('/dashboard');
            this.#store.state.dashboardData = data;
            this.#render(data);
        } catch (err) {
            this.element.innerHTML = `<p class="error">${err.message}</p>`;
        }
    }

    #render(data) {
        this.element.innerHTML = `
            <h1>Dashboard</h1>
            <p>Welcome back. You have ${data.notifications} notifications.</p>
        `;
    }

    destroy() {
        this.#unsubs.forEach(fn => fn());
    }
}

5. Wiring It Together — main.js

The composition root is deliberately thin. It creates the store, boots the router, and mounts any persistent UI (nav, footer).

// static/js/main.js
import { Store }  from './state.js';
import { Router } from './router.js';
import { Nav }    from './components/Nav.js';

// Single shared store for the whole app
export const store = new Store({
    user:          null,
    count:         0,
    dashboardData: null,
});

// Persistent nav (lives outside the router outlet)
const nav = new Nav(store);
document.querySelector('header').appendChild(nav.element);

// Router
const router = new Router('#app');

router
    .register('/', async () => {
        const { HomePage } = await import('./pages/HomePage.js');
        return new HomePage(store);
    })
    .register('/counter', async () => {
        const { Counter } = await import('./components/Counter.js');
        return new Counter(store);
    })
    .register('/dashboard', async () => {
        const { Dashboard } = await import('./pages/Dashboard.js');
        return new Dashboard(store);
    })
    .register('*', async () => {
        const { NotFound } = await import('./pages/NotFound.js');
        return new NotFound();
    });

// Boot on first load
router.navigate(location.pathname);

Your Flask template just needs the entry point and an outlet:

<!-- templates/app.html -->
<header></header>
<main id="app"></main>

<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>

type="module" enables native ES modules in the browser. No bundler. No transpiler. The browser resolves imports directly.


6. File Structure

static/js/
├── main.js                  # Composition root
├── state.js                 # Reactive Store
├── router.js                # History API router
├── services/
│   └── api.js               # HTTP service layer
├── components/
│   ├── Nav.js
│   └── Counter.js
└── pages/
    ├── HomePage.js
    ├── Dashboard.js
    └── NotFound.js

Each file is an ES module. Each has a single, clear responsibility. You can open any file and understand it in isolation — the same property good React components have, without the framework dependency.


7. Performance Reality Check

Here is what this architecture gives you without any extra configuration:

Bundle size: The browser only downloads main.js, state.js, router.js, and Nav.js on first load. Each page module is fetched only when navigated to. A typical entry bundle lands under 10 KB uncompressed.

Caching: Each module file gets its own cache entry. When you update Dashboard.js, only that file is re-fetched. React bundles typically invalidate the entire bundle on any change.

Lighthouse: No framework runtime means no blocking JS to parse on first paint. A server-rendered Flask page + deferred module loading hits 100/100 consistently.

Debugging: Open DevTools → Sources → your file. You see your actual code. No source maps, no transpiled output to decode.


8. Where AI Assistance Shines

Vanilla JS with clear module boundaries is where AI coding tools perform best. Because each file has a single responsibility and no framework magic, AI can:

  • Generate a new component from a description: "Add a Notifications.js component that polls /api/notifications every 30 seconds and shows a badge count."
  • Refactor surgically: "Extract the fetch logic in Dashboard.js into services/api.js."
  • Add a new store key: "Add theme to the store (default 'light') and wire it to a ThemeToggle component."
  • Write tests: "Write unit tests for Store.subscribe() using plain Node.js — no test framework needed."

AI tools struggle with framework magic (hooks dependency arrays, context providers, reactivity compilation). They do not struggle with clean, explicit JavaScript.


When to Reach for a Framework Anyway

This architecture is not a dogma. Frameworks are genuinely better when:

  • Large teams need enforced patterns and tooling to prevent inconsistency
  • Complex server-side rendering with streaming (Next.js, Nuxt)
  • Pre-built UI component libraries would save months of design work
  • Hundreds of simultaneously active stateful components where React's reconciler earns its keep

For a Flask app serving real users — even one with significant interactivity — vanilla JS with this pattern is almost always the right call.


Summary

You now have a full modular vanilla JS architecture:

  1. Store — reactive state with Proxy, clean subscribe/unsubscribe, private fields
  2. Components — render / attach events / destroy lifecycle, event delegation, no memory leaks
  3. Router — History API navigation, automatic component teardown, lazy loading via dynamic import()
  4. Service layer — HTTP calls decoupled from components, consistent error handling
  5. main.js — thin composition root, one shared store, no framework runtime

The browser handles module resolution. Flask serves the static files. You write JavaScript.

That is the whole stack.

Was this helpful?

Get More Flask Vibe Tutorials

Join 1,000+ developers getting weekly Flask tips and AI-friendly code patterns.

No spam. Unsubscribe anytime.