Skip to content

Router: React

React meets the router in one place: RouterView.make takes the navigation model and a map from route ids to views. Fetching, access checks, and page state stay in middleware and models.

UserPageModel is keyed by Route.Output<typeof UserRoute>. Use the three-argument, self-leasing View.make overload so the router can construct it lazily with the successful middleware output:

import { View } from "@unitflow/react";
import { Link, RouterView } from "@unitflow/router/react";
import { AppRouter } from "./routes";
import { UserLoaderLive, UserPageModel } from "./models";
const UserPage = View.make(
UserPageModel,
({ profile, rename }) => (
<section>
<h2>{profile.name}</h2>
<button onClick={() => rename(`${profile.name}!`)}>
Rename locally
</button>
</section>
),
{},
);

The third argument is the important part. A two-argument View.make expects an already-resolved singleton unit; the self-leasing overload owns a keyed model lease. RouterView supplies match.provided as that key. Until UserLoader succeeds, this model and view do not exist.

The model key must exactly match the route’s Route.Output. Wiring a model with the wrong middleware contract to user fails at the routes map.

A routes-map entry is either:

  • a plain render function, which receives the bound router, its narrowed match, shared units, and rendered children; or
  • a View.make component. A self-leasing component receives middleware output as its model key automatically.
export const AppView = RouterView.make(AppRouter.model, {
routes: {
home: ({ children }) => (
<main>
<nav>
<Link
to="/users"
search={{ page: 1, sort: "asc", filter: { role: "admin" } }}
>
People
</Link>
<Link to="/users/:id" params={{ id: 1 }}>
Ada
</Link>
</nav>
{children ?? <p>Pick a page.</p>}
</main>
),
// UserLoader has already produced { user } before this lease starts.
user: UserPage,
},
notFound: () => <p>404</p>,
});

Map keys are constrained to route ids. Link targets, path params, and search params are constrained to the registered route table. Link renders a real <a href> and only intercepts ordinary left clicks, so middle-click, cmd-click, copy, and browser accessibility behavior keep working.

Views nest only where the route table declares Route.addChild or Route.layout. Mirror that hierarchy with { view, routes }:

export const NestedAppView = RouterView.make(AppRouter.model, {
routes: {
project: {
view: ({ match, children }) => (
<section>
<h2>Project {match.params.projectId}</h2>
{children ?? <p>Overview.</p>}
</section>
),
routes: {
edit: () => <p>Edit form…</p>,
},
},
},
});

The project view receives the edit page as children only for /projects/:projectId/edit. Routes that merely share a path prefix do not accidentally render inside one another.

Local Component Typing Instead of Global Registration

Section titled “Local Component Typing Instead of Global Registration”

Link/Navigate/MatchRoute get their router from React context at runtime, so TypeScript needs a static route table. Ambient Register is one option. For multiple routers or explicit imports, bind the component types instead:

export const {
Link: BoundLink,
Navigate: BoundNavigate,
MatchRoute: BoundMatchRoute,
} = RouterView.bindComponents(AppRouter.model);

Import these bound components from the routes module. Binding is type-only; the runtime router still comes from the nearest RouterView.

AppView.model owns the navigation model and the page models represented in the routes map. The runtime supplies the page model, router, middleware, and history layers:

import * as React from "react";
import { createRoot } from "react-dom/client";
import * as Layer from "effect/Layer";
import { Unitflow, UnitflowRuntime } from "@unitflow/react";
import { Router } from "@unitflow/router";
const layer = AppView.model.layer.pipe(
Layer.provideMerge(UserPageModel.layer),
Layer.provideMerge(AppRouter.layer),
Layer.provideMerge(UserLoaderLive),
Layer.provideMerge(Router.browserHistoryLayer),
);
const runtime = UnitflowRuntime.make(layer);
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<Unitflow runtime={runtime} rootModel={AppView.model}>
{(pages) => <AppView unit={pages} />}
</Unitflow>
</React.StrictMode>,
);

Leaving out UserLoaderLive does not compile: the route table requires the middleware tag that produces UserPageModel’s key.

For units that are not route pages—such as a session model rendered in a layout—RouterView.make accepts a Units type parameter and the component takes a units prop. See the runnable router-basic example for middleware-fed page data, and router-guard example for a session model shared with a layout.