Skip to content
Whisq v0.1.0-alpha.9

Reading route state

There’s no useRoute() hook in @whisq/router — reading the current route is done directly via router.current, a ReadonlySignal<RouteState>. Every other router primitive (RouterView, Link’s active-class) is built on subscribing to that signal.

This page covers the reactive-access shape: what RouteState exposes, how to read it inside components, and which read form (.value vs callable getter) to reach for in which context.

interface RouteState {
path: string;
params: Record<string, string>;
query: Record<string, string>;
matched: MatchedRoute[];
meta: Record<string, unknown>;
}
interface MatchedRoute {
route: RouteConfig;
params: Record<string, string>;
}
FieldTypeDescription
pathstringNormalised pathname — leading slash, no trailing slash (except "/").
paramsRecord<string, string>All :name captures across the matched chain, flattened. Nested routes contribute their own params plus ancestors’.
queryRecord<string, string>Parsed query string. Single values only — repeated keys are overwritten. Empty object when no query.
matchedMatchedRoute[]The full chain of matched routes, top-down. Length = nesting depth. Useful for breadcrumbs.
metaRecord<string, unknown>Merged meta from every matched route, in matched order (child wins on key collision). Useful for per-route flags like { requiresAuth: true }.

Use the callable getter form (() => router.current.value.<field>) anywhere Whisq expects a reactive value — text children, reactive props, inside when() / match():

import { component, div, h1, p, span } from "@whisq/core";
import { router } from "../router";
export const UserDetail = component(() =>
div(
h1(() => `User ${router.current.value.params.id}`),
p(() => `Path: ${router.current.value.path}`),
// query: ?tab=settings → "settings"
p(() => `Tab: ${router.current.value.query.tab ?? "profile"}`),
),
);

The render callback’s params argument is a Record<string, string> aggregated from all matched routes up to and including this view’s depth — sugar so shallow components don’t need to import router:

// Reads the params passed by RouterView for this depth.
export const UserDetail = ({ id }: Record<string, string>) =>
div(h1(`User ${id}`));

This is a plain object snapshot captured when the component mounts. It’s equal to router.current.value.params when the component sits at the deepest matched level, but a shallower view’s render sees a proper subset (its own params plus ancestors’, not descendants’).

RouterView re-mounts the component on every route change, so the snapshot is always for the current match — no stale captures across navigations. But inside an already-mounted component, if you need a value that reads reactively without a re-mount (uncommon — happens mostly with hash-only changes or programmatic rewrites), reach for the signal:

// Always reads the current value — works even if the framework
// short-circuits a re-mount for an identical route swap.
h1(() => `User ${router.current.value.params.id}`)

router.current.value establishes a subscription, so the effect re-runs on every route change:

import { effect } from "@whisq/core";
effect(() => {
const route = router.current.value;
document.title = route.meta.title as string | undefined ?? "App";
});

If you only care about a specific field, read just that to keep the effect’s dependency set tight:

effect(() => {
const tab = router.current.value.query.tab ?? "profile";
loadTab(tab);
});

The effect still re-runs on every navigation (since router.current is a single signal), but reading the narrow projection up front makes the intent obvious and keeps the effect body focused.

For one-shot reads in non-reactive contexts (event handlers, analytics calls):

button({
onclick: () => {
const current = router.current.peek().path;
analytics.track("logout", { from: current });
},
}, "Log out")

.peek() reads the current value without entering the tracking scope — the effect or component around this button won’t re-render on route change just because it read the path.

  • Breadcrumbs — iterate router.current.value.matched, reading meta.title (or another per-route field) on each MatchedRoute.
  • Per-route page titleeffect(() => document.title = router.current.value.meta.title ?? "App") as shown above. Prefer this over useHead inside per-route components when the title logic lives outside the routes.
  • Query-string-driven state — treat router.current.value.query.x as the source of truth for a query-gated UI mode; write back via router.navigate with a rebuilt query string.

Docs current to v0.1.0-alpha.9 . All releases →