Expressive MVC
Guides

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 react

The 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.

PropMeaning
toSegment or pattern. :name captures a param; a trailing * catches the remaining segments. Omit for an index route.
asPage or layout Component. A layout receives matched children.
defaultMatches when no sibling in this scope did. Root = app 404; nested = section 404.
redirectStatic redirect or synchronous/async entry guard.
fallbackSuspense fallback on cold load. In-app navigation normally holds the outgoing page.
label / metaDisplay 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>
  );
}
  • match contains this route's captured path params.
  • matched is the boolean render dependency for a Route itself.
  • path is the route's absolute pattern path.
  • query is 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.

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 push

go(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.

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 resultOutcome
non-empty stringRedirect there, replacing history.
undefined, false, or ''Allow the route.
nullCede 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

  • Router is headless and owns an in-memory history stack. Use it in tests and non-browser hosts.
  • BrowserRouter binds window.location and window.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 Router and render navigation controls from native host components. BrowserRouter, Link, and NavLinks are 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

Loading sandbox...

Next

  • Components — Suspense, fallbacks, and error boundaries.
  • Async state — data ownership and cancellation.
  • Context — how pages find their nearest Route and Router.

On this page