Recipe: Routing + URL sync
A small client-side router for an island inside a server-rendered page. The
URL is the source of truth: clicking a tab writes the hash, the browser
fires hashchange, you read the URL back into state, and Micra re-renders.
Back / forward, copy-paste, and location.hash = … all go through the same
one-way pipe.
Markup
<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>
Component
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'
const [path, qs] = hash.split("?");
const route = (path.replace(/^\//, "") || "home").split("/")[0];
const filter = new URLSearchParams(qs || "").get("filter") || "all";
this.state.route = route;
this.state.filter = filter;
},
// state → URL (the URL stays the single source of truth)
_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);
},
});
Why URL → state, not state → URL?
The instinct is to make state primary and push it to the URL. That breaks
back / forward: the hash changes but your state object hasn’t moved, so you end
up detecting “external” URL changes anyway. Flipping it — navigate() writes
the URL, the browser fires hashchange, _readUrl() reads it into state, Micra
re-renders — means every path (clicks, back/forward, copy-paste, programmatic
location.hash) goes through one pipe.
Hash vs History API
Hash routing needs no server cooperation — every refresh hits the same HTML
file, which is exactly right for an island inside a server-rendered page. The
History API (pushState / popstate) gives clean URLs but requires the server
to serve the same HTML for every route. To switch: replace window.location.hash
with history.pushState(null, "", "/projects?filter=active"), listen only to
popstate, and call _readUrl() from navigate() yourself (pushState doesn’t
fire a popstate).
Notes
- The
hashchange/popstatelisteners are document/window-level, so they live inonCreateand are torn down inonDestroy— the one place Micra sanctions manualaddEventListener. data-if(notdata-show) unmounts the inactive views, so off-route content isn’t in the DOM at all.