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