Router
Nested class-based routing, navigation state, guards, and Suspense
@expressive/router is a small client router built on Expressive MVC. Router
owns reactive location and history state, Route matches a lexical JSX tree,
and Link is an extendable Component. It supports React through
@expressive/react, but the router core imports only @expressive/mvc.
npm install @expressive/router @expressive/react reactThe package is pre-1.0. Its public route and navigation contract is tested at 100% coverage, but URL and browser conveniences are still being completed.
Start a browser router
import '@expressive/react';
import { BrowserRouter, Route } from '@expressive/router';
export const App = () => (
<BrowserRouter>
<Route as={Layout}>
<Route as={Home} />
<Route to="missions" as={Missions}>
<Route as={MissionList} />
<Route to=":codename" as={Mission} />
<Route default as={MissionNotFound} />
</Route>
<Route default as={NotFound} />
</Route>
</BrowserRouter>
);Routes nest to mirror the URL. to is the segment or pattern, as is the page
or layout, and a layout receives the matched child through children.
| Prop | Meaning |
|---|---|
to | Segment or pattern. :name captures a param; a trailing * catches the remaining segments. Omit for an index route. |
as | Page or layout Component. A layout receives matched children. |
default | Matches when no sibling in this scope did. Root = app 404; nested = section 404. |
redirect | Static redirect or synchronous/async entry guard. |
fallback | Suspense fallback on cold load. In-app navigation normally holds the outgoing page. |
label / meta | Display data for navigation and breadcrumbs; ignored by matching. |
Sibling routes use first-match declaration order. A parent-less Route with no
to is a root scope. A Route without an ambient router creates a headless
in-memory Router; use BrowserRouter when the address bar participates.
Layouts and scoped not-found pages
Nesting is not only visual grouping. It creates a route scope:
<Route to="missions" as={MissionLayout}>
<Route as={MissionList} />
<Route to=":codename" as={Mission} />
<Route default as={MissionNotFound} />
</Route>MissionLayout remains mounted while its child changes, so its state survives.
The nested default catches only misses below /missions. A flat
to="missions/:codename" leaf resolves the same valid URL, but cannot own that
layout or section fallback.
Read the matched route
A page reads its nearest Route like any contextual State:
import { Route } from '@expressive/router';
function Mission() {
const { match, query, goto } = Route.get(true);
return (
<article>
<h2>Operation {match.codename}</h2>
<button onClick={() => goto({ codename: 'thunderball' })}>
Next mission
</button>
<p>view: {query.get('view') ?? 'summary'}</p>
</article>
);
}matchcontains this route's captured path params.matchedis the boolean render dependency for a Route itself.pathis the route's absolute pattern path.queryis the router-wide reactive query Map.goto(string)resolves relative to this route; an absolute string starts at/.goto(params)changes params declared by this route while preserving the others.resolve(string)returns the resolved path without navigating.
Navigating from /missions/a to /missions/b preserves the page instance.
match updates reactively instead of remounting the route.
Navigation, query, and fragment state
Router exposes the canonical location as path, query, hash, and derived
url:
router.goto('/missions?sort=urgency#briefing');
router.goto('/missions', true); // replace current history entry
router.url = '/missions?sort=urgency#briefing'; // push
router.back();
router.go(-2);
router.query.get('page');
router.query.set('page', '2');
router.query.delete('sort');
router.query.clear();
router.hash; // '' or a leading-# string
router.hash = '#briefing'; // preserve path/query and pushgo(delta) moves through history by an arbitrary relative offset; back() is
the common go(-1) convenience. Finite fractional deltas are truncated. Zero,
non-finite, and out-of-range deltas do nothing, so go(0) never reloads the
document.
The query is a reactive Map<string, string>, not an object. Reading one key
subscribes to that key. set, delete, and clear navigate through the same
settlement path as goto; each effective mutation pushes an entry. URL-driven
changes reconcile the existing Map in place.
Query values are strings. Repeated keys collapse to the last value and the URL
is canonically serialized with URLSearchParams rules. The hash remains an
opaque, percent-encoded string. Hash-only links preserve the current path and
query. Fragment navigation does not scroll or focus an element automatically.
Links and active links
Link renders an anchor and intercepts plain left-clicks. Modifier/middle
clicks, non-_self targets, downloads, and external targets remain browser
behavior; a consumer onClick runs first and can call preventDefault().
import { Link } from '@expressive/router';
<Link to="missions">Missions</Link>
<Link to="/login" replace>Sign in</Link>There is no separate NavLink. Extend Link and read its lazy active or
match getter:
class NavLink extends Link {
render(): Component.Node {
return (
<a
href={this.href}
onClick={this.go}
className={this.active ? 'active' : undefined}
aria-current={this.active ? 'page' : undefined}>
{this.props.children}
</a>
);
}
}An overridden render replaces the base anchor. Host-agnostic packages should
annotate it as Component.Node so their emitted declarations retain the host
node seam.
NavLinks can render the declared route hierarchy. Override its Item, List,
and Group subcomponents to match the application navigation.
Redirects and entry guards
Redirect navigates when it mounts:
<Redirect to="/login" when={!session.authorized} replace />The Route.redirect prop runs before a matched route enters:
| Guard result | Outcome |
|---|---|
| non-empty string | Redirect there, replacing history. |
undefined, false, or '' | Allow the route. |
null | Cede the path to the nearest scoped default. |
<Route
to="admin"
fallback={<Spinner />}
redirect={async () => (session.authorize() ? undefined : '/login')}
as={Admin}
/>An async guard suspends. On cold load its fallback renders; during in-app
navigation the previous page normally remains visible. Guard verdicts are
cached while navigation stays inside the matched route space and re-evaluated
after leaving and re-entering it.
Use null for a resource that should behave as not found. Put the leaf inside a
scope with a default so there is somewhere local to fall through.
Lazy pages, errors, and pending navigation
as accepts React.lazy pages. A Route is its Suspense boundary:
const Report = lazy(() => import('./Report'));
<Route to="reports/:id" fallback={<Spinner />} as={Report} />;Every navigation passes through protected Router.navigate(work). Its default
uses the host transition scheduler, so an in-app navigation which suspends holds
the outgoing screen instead of flashing the route fallback. Cold load still
uses fallback because no prior screen exists.
router.navigating is true until the current navigation appears. Read it from a
sibling or wrapper around the routed content:
function NavigationStatus() {
const { navigating } = BrowserRouter.get();
return <progress aria-label="Loading page" hidden={!navigating} />;
}
<BrowserRouter>
<NavigationStatus />
<AppRoutes />
</BrowserRouter>;Do not read navigating in the component that reconstructs the deferred route
tree; that urgent read can forfeit the hold. Overlapping navigation is
latest-wins: superseded work cannot later change the page, history, or status.
For goto, Link, and query writes, BrowserRouter updates the address after
the screen settles. Browser Back/Forward and external History API calls change
the address first, because the browser owns those operations.
A rejected lazy import is an error, not a suspension. Handle it with
Component.catch on a Route subclass or an ancestor Component boundary.
Keep page data in MVC State
The router does not own loaders, mutations, a cache, or revalidation. Put those in the route/page State and let its async fields suspend through the Route:
class ProjectPage extends Component {
route = get(Route);
project = set(async (self) => {
const id = self.route.match!.id;
const response = await fetch(`/api/projects/${id}`);
if (!response.ok) throw new Error('Project unavailable');
return response.json() as Promise<Project>;
});
render() {
return <ProjectView project={this.project} />;
}
}
<Route to="projects/:id" fallback={<Spinner />} as={ProjectPage} />;The navigation layer prevents stale router commits; it does not abort domain
requests. If an operation has side effects or consumes significant resources,
the owning State should use AbortController or its own generation check.
Browser, server, and native boundaries
Routeris headless and owns an in-memory history stack. Use it in tests and non-browser hosts.BrowserRouterbindswindow.locationandwindow.history. It is for client-routed browser applications.- Server rendering is safe from shared global router state, but request-path routing, redirects, loader serialization, and hydration are not a supported SSR routing system today.
- On React Native, use
Routerand render navigation controls from native host components.BrowserRouter,Link, andNavLinksare DOM-facing.
Current location state is pathname, single-valued query parameters, and an
opaque fragment. Basename mounting, arbitrary history.state, automatic
scroll-to-anchor, scroll restoration, navigation blockers, external URL
routing, and data-router behavior are not part of the supported contract yet.
Link preserves scheme-bearing and protocol-relative targets as browser-owned
anchors; use the host router for a case that needs to route them through the
application.
Testing
Use Router for deterministic route tests:
it('renders a mission', async () => {
const router = Router.new({ path: '/missions/moonraker' });
render(
<Provider for={router}>
<AppRoutes />
</Provider>
);
expect(screen.getByText('Operation moonraker')).toBeVisible();
router.set(null);
});Use BrowserRouter integration tests when behavior depends on the address bar,
History API, query serialization, Back/Forward, or navigation settlement. Await
the UI with the renderer's normal async helpers; goto() intentionally returns
void, while navigating reports presentation state.
See it running
Next
- Components — Suspense, fallbacks, and error boundaries.
- Async state — data ownership and cancellation.
- Context — how pages find their nearest Route and Router.