Skip to content

Routing

Build a single-page router on top of the platform: history.pushState + URLPattern, wrapped in reactive helpers. You get reactive route matching, named params, and same-origin link interception in about 30 lines.

The recipe

import { effect } from "elements-kit/signals";
import {
patchHistory,
navigate,
matches,
match,
isLocalNavigationEvent,
} from "elements-kit/utilities/routing";
import { currentLocation } from "elements-kit/utilities/location";
patchHistory(); // call once at boot
const isHome = matches({ pathname: "/" });
const userMatch = match({ pathname: "/users/:id" });
const isSettings = matches({ pathname: "/settings" });
effect(() => {
const id = userMatch()?.pathname.groups.id;
document.title = id ? `User ${id}` : isHome() ? "Home" : "App";
});
document.addEventListener("click", (e) => {
if (!isLocalNavigationEvent(e)) return;
e.preventDefault();
const a = (e.target as Element).closest("a") as HTMLAnchorElement;
navigate(a.href);
});

How each piece contributes

PrimitiveRole
patchHistory()Monkey-patches pushState / replaceState to dispatch synthetic events. Without it, programmatic navigation is silent.
matches(pattern)Computed<boolean> — true when the current URL matches. Use for boolean route gates.
match(pattern)Computed<URLPatternResult | null> — full match including pathname.groups for params.
currentLocation{ href, pathname, search, hash } — each a Computed<string> derived from the patched history events.
navigate(url, opts?)Wraps pushState (or replaceState with { replace: true }).
isLocalNavigationEvent(e)Predicate: same origin, primary button, no modifiers, no target="_blank" / download.

Variations

Redirect — replace the entry instead of pushing a new one:

navigate("/login", { replace: true });

Read paramsmatch() returns URLPatternResult, so groups are typed as string | undefined:

const route = match({ pathname: "/users/:id/posts/:postId" });
effect(() => {
const r = route();
if (r) console.log(r.pathname.groups.id, r.pathname.groups.postId);
});

Route-level data loading — compose with async. The query in Data fetching re-runs whenever its tracked signals change, including currentLocation.pathname() or any match() getter:

const userMatch = match({ pathname: "/users/:id" });
const userQuery = async(() => {
const id = userMatch()?.pathname.groups.id;
if (!id) return null;
return fetch(`/api/users/${id}`).then((r) => r.json());
}).start();

Gotchas

See also

  • Utilities — full routing reference (navigate, matches, match, isLocalNavigationEvent).
  • Data fetching — pair routing with async for route-level loaders.
  • Signalseffect for side-effects like document title.