Build an app-like experience with client-side routing and instant transitions. Perfect for dashboards, tools, and interactive web apps.
Creates a new project folder and initializes package.json
Downloads Domma from npm (~260KB minified)
Creates a single page application with client-side routing
frontend/index.html in your browser or run:
{
"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" }
]
}
}
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');
}
};
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');
});
});
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.
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: '' }
});
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
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);
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
}
};
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')
);
});
await, so fetch first and write the result to the model - don't read model fields after
awaiting.
[...items, newItem] instead.
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().
Your SPA is set up and ready. Start editing views in frontend/js/views/ folder.