⚡ Single Page Application

SPA QuickStart

Build an app-like experience with client-side routing and instant transitions. Perfect for dashboards, tools, and interactive web apps.

Instant Transitions Client Routing Shared State

Setup Your SPA in 3 Steps

1

Create & Initialize Project

$ mkdir my-spa-app && cd my-spa-app
$ npm init -y

Creates a new project folder and initializes package.json

2

Install Domma

$ npm install domma-js

Downloads Domma from npm (~260KB minified)

3

Generate SPA Structure

$ npx domma init --spa

Creates a single page application with client-side routing

✓ Generated File Structure:

frontend/index.html (single entry)
frontend/js/app.js (router init)
frontend/js/views/
home.js
about.js
contact.js
404.js
frontend/css/custom.css
frontend/domma.config.json
✓ Ready! Open frontend/index.html in your browser or run:
$ npx live-server frontend

Live SPA Router Demo

Click the navigation links below to switch between views without page reload. This is how your SPA will work!

Notice: The content changes instantly without reloading the page. That's the power of client-side routing!

Code Structure

frontend/domma.config.json

{
  "project": {
    "name": "My SPA App",
    "version": "1.0.0"
  },
  "spa": {
    "enabled": true,
    "container": "#app",
    "defaultRoute": "/",
    "notFoundView": "404"
  },
  "routes": [
    { "path": "/", "view": "home" },
    { "path": "/about", "view": "about" },
    { "path": "/contact", "view": "contact" }
  ],
  "navbar": {
    "brand": { "text": "My App", "url": "#/" },
    "items": [
      { "text": "Home", "url": "#/" },
      { "text": "About", "url": "#/about" },
      { "text": "Contact", "url": "#/contact" }
    ]
  }
}

frontend/js/views/home.js

export const homeView = {
  template: `
    <div class="hero hero-gradient-primary hero-center" style="padding: 3rem 2rem;">
      <div class="hero-content">
        <h1>Welcome to My SPA</h1>
        <p class="lead">Built with Domma's powerful router</p>
      </div>
    </div>

    <div class="container py-6">
      <h2>This is the Home View</h2>
      <p>Click the navigation links to switch views without page reload!</p>
    </div>
  `,

  onMount($container) {
    // Scan for icons when view mounts
    Domma.icons.scan($container[0]);
    console.log('Home view mounted');
  },

  onLeave() {
    console.log('Home view unmounted');
  }
};

frontend/js/app.js

import { views } from './views/index.js';

$(() => {
  // Load config
  Domma.http.get('domma.config.json').then(config => {
    // Initialize router
    R.init({
      container: config.spa.container || '#app',
      routes: config.routes || [],
      views: views,
      default: config.spa.defaultRoute || '/',
      notFound: config.spa.notFoundView || '404'
    });

    // Subscribe to route changes
    M.subscribe('router:afterChange', ({ to, from }) => {
      console.log(`Route changed: ${from?.path} → ${to.path}`);
      window.scrollTo({ top: 0, behavior: 'smooth' });
    });

    console.log('Router initialized');
  });
});

Keeping the UI in Sync

Most SPA bugs come from forgetting to update something. Domma's dependency tracking removes that class of bug - you describe what a value is, and it stays correct.

Step 1 - Hold state in a model

Start with a blueprint and a model. Nothing new here.

const cart = M.create({
    items:    { type: M.types.array,  default: [] },
    shipping: { type: M.types.number, default: 4.99 },
    query:    { type: M.types.string, default: '' }
});

Step 2 - Derive, don't duplicate

A computed describes a value in terms of others. It runs when first read, then caches until something it read changes.

const subtotal = M.computed(() =>
    cart.get('items')
        .reduce((sum, i) => sum + i.price, 0)
);

const total = M.computed(() =>
    subtotal.get() + cart.get('shipping')
);

total.get();   // computed once, then cached

Step 3 - React with an effect

An effect subscribes to whatever it reads. Add a field to the calculation later and the subscription updates itself - there is no list of field names to keep in step.

M.effect(() => {
    $('#cart-total').text(`£${total.get().toFixed(2)}`);
});

// Anything that changes the total updates the DOM
cart.set('shipping', 0);

Step 4 - Clean up on route change

M.effect() returns a stop function. Call it in your view's onLeave so effects don't outlive the view that created them.

export const cartView = {
    templateUrl: 'js/views/templates/cart.html',

    onMount($container) {
        this._stop = M.effect(() => {
            $container.find('#total')
                      .text(total.get());
        });
    },

    onLeave() {
        this._stop();   // unsubscribe
    }
};

Why this beats wiring it by hand

The manual way - quietly breaks

cart.onChange(({ field }) => {
    // Miss a field name here and the
    // total silently goes stale
    if (field === 'items' ||
        field === 'shipping') {
        recalculate();
    }
});

Tracked - cannot go stale

M.effect(() => {
    // Subscribes to exactly what it
    // reads, every time it runs
    recalculate(
        cart.get('items'),
        cart.get('shipping')
    );
});
Two rules worth remembering.
  1. Keep derivations synchronous. Dependency tracking stops at the first await, so fetch first and write the result to the model - don't read model fields after awaiting.
  2. Return new values, don't mutate old ones. A computed that pushes to an existing array and returns it looks unchanged, so nothing downstream updates. Return [...items, newItem] instead.
Updates are batched: several set() calls in the same tick produce one re-run on the next microtask. If you need a derived value settled immediately - in a test, or when reading straight after a write - call M.flush().

Try it live in the Reactivity showcase

You're Ready to Build! 🎉

Your SPA is set up and ready. Start editing views in frontend/js/views/ folder.