SPA QuickStart

An app-like experience with client-side routing and instant view changes. Ideal for dashboards, tools and interactive web apps.

Instant view changes Client-side routing Shared state

Set up your SPA in three steps

1
1
Create the project
mkdir my-app
cd my-app
npm init -y
2
2
Install Domma
npm install domma-js

About 175 KB gzipped.

3
3
Generate the SPA
npx domma-js init --spa

A single-page app with client-side routing.

What you get

frontend/
├── index.html              single entry point
├── domma.config.json       routes, navbar, theme
├── css/custom.css          your styles
└── js/
    ├── app.js              starts the router
    └── views/
        ├── index.js        the view registry
        ├── home.js  about.js  contact.js  404.js
        └── templates/      each view's HTML

Open frontend/index.html, or serve it:

npx live-server frontend

How routing works

Clicking a link swaps the view inside #app without reloading the page - the router (R) matches the hash, unmounts the old view and mounts the new one. See it working, with code, in the Router showcase.

The code

frontend/domma.config.json

{
  "project": { "name": "My SPA App", "version": "1.0.0" },
  "spa": {
    "enabled": true,
    "container": "#app",
    "defaultRoute": "/",
    "notFoundView": "404",
    "transitions": { "enter": "fadeIn", "leave": "fadeOut", "duration": 200 }
  },
  "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

A view points at its template file and has lifecycle hooks:

export const homeView = {
    templateUrl: 'js/views/templates/home.html',

    onMount($container) {
        Domma.icons.scan($container[0]);   // icons in the new view
    },

    onLeave() {
        // tidy up timers, listeners and effects here
    }
};

frontend/js/app.js

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

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

        // React to route changes
        M.subscribe('router:afterChange', ({ to, from }) => {
            window.scrollTo({ top: 0, behavior: 'smooth' });
        });
    });
});

Keeping the UI in sync

Most SPA bugs come from forgetting to update something. Domma's dependency tracking removes that whole 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 page
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 made 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

By hand - 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:

  • 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.
  • 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 cause one re-run, on the next microtask. If you need a derived value settled at once - in a test, or 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. Start editing the views in frontend/js/views/.

Back to QuickStart Explore the Showcase