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
| Primitive | Role |
|---|---|
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 params — match() 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
routingreference (navigate,matches,match,isLocalNavigationEvent). - Data fetching — pair routing with
asyncfor route-level loaders. - Signals —
effectfor side-effects like document title.