Recipe: Routing + URL sync
Hash-based routing for small SPA islands inside a server-rendered page, with filters persisted to the querystring. The URL is the source of truth — state mirrors it. Browser back / forward works, copy-pasted links restore the same view.
Live preview
Switch tabs and filters, then use the browser's back button. The URL fragment below the panel is what would land in the address bar.
Welcome back. Recent activity, quick actions, the usual.
Filter projects by status:
Profile, billing, security — everything per-account.
Full source
<div data-component="router">
<nav>
<button @click="navigate" data-bind="data-route:'home'" data-class="active:route === 'home'" >Home</button>
<button @click="navigate" data-bind="data-route:'projects'" data-class="active:route === 'projects'">Projects</button>
<button @click="navigate" data-bind="data-route:'settings'" data-class="active:route === 'settings'">Settings</button>
</nav>
<div data-if="route === 'home'" >…home…</div>
<div data-if="route === 'projects'">
<button @click="setFilter" data-bind="data-filter:'active'">Active</button>
<!-- … -->
</div>
<div data-if="route === 'settings'">…settings…</div>
</div>
Micra.define('router', {
state: { route: 'home', filter: 'all' },
onCreate() {
this._sync = () => this._readUrl()
window.addEventListener('hashchange', this._sync)
window.addEventListener('popstate', this._sync)
this._readUrl() // initial pass
},
onDestroy() {
window.removeEventListener('hashchange', this._sync)
window.removeEventListener('popstate', this._sync)
},
// URL → state
_readUrl() {
const hash = window.location.hash.slice(1) || '/home' // '#/home?filter=active' → '/home?filter=active'
const [path, qs] = hash.split('?')
const route = (path.replace(/^\//, '') || 'home').split('/')[0]
const params = new URLSearchParams(qs || '')
const filter = params.get('filter') || 'all'
this.state.route = route
this.state.filter = filter
},
// state → URL (single source of truth: the URL)
_writeUrl(route, filter) {
const qs = filter !== 'all' ? '?filter=' + encodeURIComponent(filter) : ''
const next = '#/' + route + qs
if (window.location.hash !== next) window.location.hash = next
},
navigate(e) { this._writeUrl(e.currentTarget.dataset.route, this.state.filter) },
setFilter(e) { this._writeUrl(this.state.route, e.currentTarget.dataset.filter) },
})
Micra.start()
Why URL → state, not state → URL?
The natural instinct is to make state primary and push it to the URL when it changes. That breaks browser back / forward: the URL updates, the user clicks Back, the hash changes — but your state object hasn't moved, and now you have to detect "external" URL changes anyway.
Making the URL the source of truth flips that: navigate() writes the URL, the browser fires hashchange, your _readUrl reads the new URL into state, Micra re-renders. Back / forward, copy-paste, programmatic location.hash = … — all go through the same one-way pipe.
Hash vs History API
Hash routing works without server cooperation — every refresh hits the same HTML file. The History API (pushState / popstate) gives clean URLs but requires the server to serve the same HTML for every route. For an SPA island inside a server-rendered page, hash is the simpler choice; for a standalone SPA on a custom domain, History API is worth the server config.
To switch: replace window.location.hash with history.pushState(null, '', '/projects?filter=active'), listen only to popstate, and call _readUrl from navigate manually since pushState doesn't fire a popstate.
Pitfalls
- Don't update state directly in navigate(). Write the URL and let hashchange drive state. One pipe = no drift between displayed URL and rendered view.
- Don't forget the popstate listener. hashchange covers most cases, but history.back() on a pushState-driven app fires only popstate.
- Don't leave default filters in the URL. ?filter=all bloats every link. The filter !== 'all' guard in _writeUrl keeps URLs short when the user hasn't actually narrowed anything.
- Don't skip the no-op guard. if (window.location.hash !== next) prevents an infinite loop when _writeUrl is called from a code path that didn't actually change anything.
- Don't put unsafe characters into the hash directly. Always encodeURIComponent any value that could contain &, ?, or #. Use URLSearchParams for building too — it handles encoding automatically.