-
-
Notifications
You must be signed in to change notification settings - Fork 0
React Integration
@real-router/react — React adapter for Real-Router. Ships five named subpath entries (main client, /ssr, /legacy, /legacy/ssr, /ink) plus type-only react-server condition entries on . and /ssr for Server Components.
npm install @real-router/react @real-router/core @real-router/browser-pluginPeer dependencies:
-
react+react-dom≥ 18.0.0 (main / legacy) -
react+react-dom≥ 19.2.0 (main only —<Activity>API) -
ink≥ 7.0.0 (for the/inkentry only, optional)
For other frameworks, see Preact · Solid · Vue · Svelte · Angular · Ink (terminal UI).
import { createRouter } from "@real-router/core";
import { browserPluginFactory } from "@real-router/browser-plugin";
import {
RouterProvider,
RouteView,
Link,
useRouteNode,
} from "@real-router/react";
const router = createRouter([
{ name: "home", path: "/" },
{
name: "users",
path: "/users",
children: [
{ name: "list", path: "/list" },
{ name: "profile", path: "/:id" },
],
},
]);
router.usePlugin(browserPluginFactory());
router.start();
function App() {
return (
<RouterProvider router={router}>
<nav>
<Link routeName="home">Home</Link>
<Link routeName="users">Users</Link>
</nav>
<RouteView nodeName="">
<RouteView.Match segment="home">
<HomePage />
</RouteView.Match>
<RouteView.Match segment="users">
<UsersLayout />
</RouteView.Match>
<RouteView.NotFound>
<NotFoundPage />
</RouteView.NotFound>
</RouteView>
</RouterProvider>
);
}
function UsersLayout() {
const { route } = useRouteNode("users");
if (!route) return null;
return <div>Active: {route.name}</div>;
}Five entry points via package.json exports (plus a react-server condition on . and ./ssr):
| Entry point | Import path | React version | Runtime | What's included |
|---|---|---|---|---|
| Main | @real-router/react |
19.2+ | DOM | Full client API including RouteView with keepAlive (React <Activity>). No SSR-feature components — those live at /ssr
|
| SSR | @real-router/react/ssr |
19.2+ | DOM (SSR-aware) |
<ClientOnly>, <ServerOnly>, <Await>, <Streamed>, useDeferred — components/hooks that participate in the SSR pipeline |
| Legacy | @real-router/react/legacy |
18+ | DOM | Client API for React 18 (no RouteView, no SSR helpers) |
| Legacy SSR | @real-router/react/legacy/ssr |
18+ | DOM (SSR-aware) | SSR-feature subset for React 18 — same as /ssr minus <Await> (which depends on React 19's use(promise)) |
| Ink | @real-router/react/ink |
19.2+ + Ink 7+ | Terminal |
InkRouterProvider + InkLink + hooks; no Link, no RouteView, no announceNavigation
|
// React 19.2+ (recommended) — client-only API
import { RouterProvider, useRouteNode, Link, RouteView } from "@real-router/react";
// React 19.2+ — SSR-feature components/hooks
import { ClientOnly, ServerOnly, Await, Streamed, useDeferred } from "@real-router/react/ssr";
// React 18+ — client-only API
import { RouterProvider, useRouteNode, Link } from "@real-router/react/legacy";
// React 18+ — SSR-feature subset (no <Await> — uses React 19's use())
import { ClientOnly, ServerOnly, Streamed, useDeferred } from "@real-router/react/legacy/ssr";
// Terminal UIs via Ink v7+
import { InkRouterProvider, useRouteNode, InkLink } from "@real-router/react/ink";Decision matrix:
- Touching the SSR pipeline (
<Suspense>+defer()consumers, hydration-aware boundaries)? → import from/ssr. - On React 18 or unable to upgrade? → use
/legacyfor client API +/legacy/ssrfor SSR helpers. - Building a CLI / terminal UI? → use
/ink. See Ink Integration. - New project or already on React 19.2+? → main entry +
/ssras needed.
The main entry under the react-server condition (Vite RSC, Webpack RSC, Turbopack, Parcel) resolves to a type-only entry — Server Components can import public API types without pulling client-only code into the server bundle. Per-request data fetching lives in @real-router/rsc-server-plugin, not the main entry. See RSC Integration.
All entries share the same underlying source — switching between them is a purely mechanical import swap.
React 18+ ships useSyncExternalStore natively. Hooks like useRouteNode delegate to it directly — no polyfill, no custom subscription plumbing. This is StrictMode-safe: double-mount in development reuses the shared subscription from @real-router/sources.
RouteView.Match supports a keepAlive prop on React 19.2+. Unlike Solid/Svelte/Preact where inactive routes unmount, keepAlive preserves the component tree via React's <Activity> API — form state, scroll position, uncommitted inputs all survive navigating away and coming back.
<RouteView nodeName="">
<RouteView.Match segment="editor" keepAlive>
<DocumentEditor /> {/* state preserved while navigated away */}
</RouteView.Match>
<RouteView.Match segment="preview">
<PreviewPage />
</RouteView.Match>
</RouteView>Combines with fallback for lazy-loaded routes — <Activity> wraps the <Suspense> boundary.
Link uses React.memo() with a custom areLinkPropsEqual comparator:
- Primitive props (
routeName,className, etc.) compared viaObject.is. -
routeParams/routeOptionscompared viashallowEqual(Object.is per key, key-order insensitive). - 99% of Links pass
routeParams=undefinedand hit theObject.is(undefined, undefined)fast path.
Nested objects in params are not deep-compared — stabilize via useMemo if needed.
useRouteNode, useIsActiveRoute, useRouterTransition, RouterErrorBoundary all use cached factories from @real-router/sources. N consumers of the same nodeName share one router subscription, not N. Cache is per-router WeakMap — auto-evicts on router GC.
React hooks read route state through useSyncExternalStore:
// Simplified internal pattern — useRouteNode
const store = useMemo(() => createRouteNodeSource(router, name), [router, name]);
const snapshot = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot, // SSR fallback — same snapshot on server and client
);The third argument (getServerSnapshot) is provided because the router returns the same state on server and client — no hydration mismatch.
RouteView.Match and RouteView.NotFound are real React components, but RouteView identifies them by element type (not by rendering them). That's why passing RouteView.Match outside RouteView does nothing — it's a marker, not a renderable component.
// CORRECT — used inside RouteView
<RouteView nodeName="">
<RouteView.Match segment="users">
<UsersPage />
</RouteView.Match>
</RouteView>
// WRONG — Match is a marker, renders nothing on its own
const el = <RouteView.Match segment="users"><UsersPage /></RouteView.Match>;Unlike Preact and Solid (which use Symbol $$type markers), React RouteView walks children via Children.toArray() and matches element.type === RouteView.Match.
Multi-entry config produces shared chunks for code used across entries:
dist/
├── esm/
│ ├── index.mjs # Main (React 19.2+)
│ ├── legacy.mjs # Legacy (React 18+)
│ ├── ink.mjs # Ink (terminal)
│ └── chunk-*.mjs # Shared hooks / utilities
└── cjs/
└── (same layout)
Tree-shakeable — importing from /legacy doesn't pull the <Activity>-based RouteView.
| Export | Description |
|---|---|
RouterProvider |
Root provider — pass router prop. Supports announceNavigation, scrollRestoration, scrollSpy, viewTransitions. |
RouterProvider accepts an optional announceNavigation boolean prop. When true, a visually hidden aria-live="assertive" region announces each route change and focus moves to the first <h1> on the new page. See Accessibility for full details.
<RouterProvider router={router} announceNavigation>
<App />
</RouterProvider>RouterProvider also accepts an optional scrollRestoration prop. When set to an options object, scroll position is captured on navigation and restored on back/forward. The React adapter watches primitive fields (mode, anchorScrolling), so inline object literals with the same values do not re-create the utility. See Scroll Restoration.
<RouterProvider router={router} scrollRestoration={{ mode: "restore" }}>
<App />
</RouterProvider>RouterProvider also accepts an optional scrollSpy prop. When the URL hash should track the topmost visible anchor (long-form articles, TOC sidebars), pass { selector }. The React adapter watches primitive fields (selector, rootMargin), so inline objects with the same values do not re-create the utility. See Scroll Spy.
<RouterProvider router={router} scrollSpy={{ selector: "[id]:is(h2,h3)" }}>
<App />
</RouterProvider>RouterProvider also accepts an optional viewTransitions boolean prop. When true, the browser's View Transitions API is wired into the router pipeline — subscribeLeave opens the VT snapshot of the old DOM and the router awaits the Promise; subscribe resolves the VT deferred via setTimeout(0). URL and UI stay in lock-step. No-op on SSR and browsers without document.startViewTransition. See View Transitions.
<RouterProvider router={router} viewTransitions>
<App />
</RouterProvider>| Export | Returns | Description |
|---|---|---|
useRouter() |
Router |
Router instance (stable) |
useNavigator() |
Navigator |
Navigator for navigate(), getState() (stable) |
useRoute() |
{ navigator, route, previousRoute } |
Current route + previousRoute (reactive) |
useRouteNode(name) |
{ navigator, route, previousRoute } |
Route state filtered by node (reactive) |
useRouteUtils() |
RouteUtils |
Route tree utilities (stable) |
useRouterTransition() |
{ isTransitioning, isLeaveApproved, toRoute, fromRoute } |
Transition state (reactive). isLeaveApproved is true between TRANSITION_LEAVE_APPROVE and TRANSITION_SUCCESS/ERROR/CANCEL — useful for "deactivation done, activation pending" loading states. |
useRouteExit(handler, opts?) |
void |
Wrap subscribeLeave with abort + same-route guards; handler can return Promise
|
useRouteEnter(handler, opts?) |
void |
Fire on nav-driven mount with { route, previousRoute }; skip-initial built in |
| Export | Description |
|---|---|
Link |
Navigation link with memo + active state |
RouteView |
Declarative route matching (main entry only, supports keepAlive) — compound .Match / .Self / .NotFound
|
RouterErrorBoundary |
Declarative navigation error handling |
InkRouterProvider / InkLink (/ink entry) |
Terminal UI primitives — see Ink Integration |
| Export | Kind | Description |
|---|---|---|
<ClientOnly fallback={…}> |
component | Server emits fallback; client swaps to children after first mount. For browser-API consumers, ad slots, dynamic widgets. |
<ServerOnly fallback={…}> |
component | Symmetric inverse — server emits children, client swaps to fallback after mount. SEO-only meta strips, zero-JS sections. |
<Streamed fallback={…}> |
component | Cross-adapter alias for <Suspense fallback={…}>. Same DOM output — naming convenience for cross-framework consistency. |
<Await<T> name="key"> |
component | React 19.2+ render-prop wrapper around use(useDeferred(name)). Reads deferred promise published by ssr-data-plugin's defer(). Main /ssr only.
|
useDeferred<T>(key) |
hook | Returns the deferred promise for key (paired with defer()). Compose with use() or <Await>. |
<HttpStatusCode code={N} /> |
component | Render-time HTTP status declaration. Writes N to the nearest <HttpStatusProvider>'s sink during render; last write wins. No-op without a provider. |
<HttpStatusProvider sink={…}> |
component | Supplies an HttpStatusSink to descendant <HttpStatusCode /> via React context. |
createHttpStatusSink() |
utility | Returns a fresh { code: number | undefined } sink — construct one per request, read sink.code after renderToString/renderToReadableStream. |
/legacy/ssr ships the same set minus <Await> (which depends on React 19's use(promise)). Pair with @real-router/ssr-data-plugin for the end-to-end deferred-data pipeline.
| Export | Description |
|---|---|
LinkProps |
Props for <Link>
|
RouteViewProps |
Props for <RouteView>
|
RouteViewMatchProps |
Props for <RouteView.Match> (includes keepAlive, fallback) |
RouteViewSelfProps |
Props for <RouteView.Self> (includes fallback) |
RouteViewNotFoundProps |
Props for <RouteView.NotFound>
|
RouterErrorBoundaryProps |
Props for <RouterErrorBoundary>
|
RouteContext<P> |
Shape consumed by useRoute() / useRouteNode()
|
UseRouteExitOptions / RouteExitContext / RouteExitHandler
|
Types for useRouteExit
|
UseRouteEnterOptions / RouteEnterContext / RouteEnterHandler
|
Types for useRouteEnter
|
Navigator |
Re-exported from @real-router/core
|
RouterTransitionSnapshot |
Re-exported from @real-router/sources
|
Not public: React contexts (RouterContext, RouteContext, NavigatorContext) live in src/context.ts and are intentionally not exported — consumers use hooks (useRouter, useRoute, useNavigator) instead. This isolates the public API from changes to the context shape.
/ssr adds the SSR-feature prop types (AwaitProps, StreamedProps, ClientOnlyProps, ServerOnlyProps, HttpStatusCodeProps, HttpStatusProviderProps, HttpStatusSink).
function UserProfile() {
const { route } = useRouteNode("users.profile");
return <h1>User: {route?.params.id}</h1>;
}useRoute<P>() accepts an optional generic to type route.params without as casts at the call site. useRouteNode() does not (yet) accept a generic — the inferred Params type is sufficient for the nullable-node usage pattern below.
type SearchParams = { q: string; sort: string } & Params;
function SearchPage() {
const { route } = useRoute<SearchParams>();
return <div>Query: {route.params.q}</div>;
}useRoute() is non-nullable: route is guaranteed defined or the hook throws (issue #535). useRouteNode() keeps the nullable shape because node inactivity is a legitimate value, so route?.params.id above is correct for that hook.
useRoute() is non-nullable — it throws when the router has no active state instead of returning undefined. Defensive if (!route) checks are obsolete. Use the hook only inside a <RouterProvider> whose router has resolved its initial start():
function ProfilePage() {
const { route } = useRoute<{ id: string }>();
return <div>User: {route.params.id}</div>; // route is guaranteed defined
}For node-scoped subscriptions where inactivity is a legitimate value, use useRouteNode() and treat route? as a real signal:
function UsersLayout() {
const { route } = useRouteNode("users");
if (!route) return null; // "users" subtree is not active
return <div>Active: {route.name}</div>;
}function BackButton() {
const navigator = useNavigator();
return <button onClick={() => navigator.navigate("home")}>Back</button>;
}function ProgressBar() {
const { isTransitioning, isLeaveApproved } = useRouterTransition();
if (!isTransitioning) return null;
return (
<div
className={isLeaveApproved ? "progress activating" : "progress leaving"}
aria-live="polite"
>
Navigating...
</div>
);
}Render only when the matched node changes, not on every navigation:
function UsersHeader() {
// Re-renders only on users.* transitions, not on e.g. "home" → "about"
const { route } = useRouteNode("users");
return route ? <h2>Users section</h2> : null;
}Link is memoized, but nested params objects change reference on every parent render. Stabilize via useMemo if needed:
function UserLink({ id }: { id: string }) {
const params = useMemo(() => ({ id }), [id]);
return <Link routeName="users.profile" routeParams={params}>View</Link>;
}For primitive-only params ({ id: "123" }), inline is fine — shallowEqual handles it at ~40ns.
RouteView.Match accepts a fallback prop. When provided, the matched content is wrapped in <Suspense> with that fallback.
import { lazy } from "react";
const LazyDashboard = lazy(() => import("./Dashboard"));
<RouteView nodeName="">
<RouteView.Match segment="dashboard" fallback={<Spinner />}>
<LazyDashboard />
</RouteView.Match>
</RouteView>Combines with keepAlive — <Activity> wraps the whole match, including the <Suspense> boundary. State survives across navigations.
Declarative handling of navigation errors — rejected guards, missing routes, thrown handlers:
import { RouterErrorBoundary } from "@real-router/react";
<RouterProvider router={router}>
<RouterErrorBoundary
fallback={(error, resetError) => (
<div role="alert">
<h2>Navigation error: {error.code}</h2>
<button onClick={resetError}>Dismiss</button>
</div>
)}
>
<App />
</RouterErrorBoundary>
</RouterProvider>The fallback renders alongside children — links remain interactive while the error is showing. The error clears automatically on the next successful navigation.
Use onError for logging or analytics without affecting the UI:
<RouterErrorBoundary
fallback={(error, resetError) => (
<div className="toast">
{error.code} <button onClick={resetError}>Dismiss</button>
</div>
)}
onError={(error, toRoute, fromRoute) => {
analytics.track("navigation_error", { code: error.code });
}}
>
<AppNavigation />
</RouterErrorBoundary>Multiple boundaries in the tree see the same error — scope is global (the router dispatches errors to all subscribers). Per-Link scoping is not supported.
See RouterErrorBoundary for full API reference.
No built-in SSR support. For SSR:
- Create router per request (don't share across requests).
- Initialize with the matched URL (
router.start(url)). -
previousRoutewill be undefined on server.
See the examples/react/ssr directory for a full Express + React 19 setup.
-
useRouteNodeuses cachedcreateRouteNodeSourcefrom@real-router/sources— one subscription per(router, nodeName)shared across all consumers. -
useRouterTransitionusesgetTransitionSource— shared eager source per router. -
RouterErrorBoundaryusescreateDismissableError— shared error source with integrated dismissal state. -
useIsActiveRouteuses cachedcreateActiveRouteSource— params hashed viacanonicalJson(key-order insensitive). -
Linkusesmemo()with customareLinkPropsEqual— primitive compare +shallowEqualforrouteParams/routeOptions. - All WeakMap caches live in
@real-router/sources— auto-evicted on router GC.
- RouterProvider · Link · RouteView · RouterErrorBoundary
- useRouter · useRoute · useRouteNode · useNavigator · useRouteUtils · useRouterTransition
- Scroll Restoration · Accessibility
-
Ink Integration — terminal UIs via
@real-router/react/ink - @real-router/sources — subscription layer used internally
- Building a Framework Adapter — how adapters work
- View-Agnostic Design
- Core Concepts
- Navigation Lifecycle
- Guards
- Plugin Architecture
- Hash Fragment Support
- Accessibility (A11y)
- Server-Side Rendering
- Data Loading
- Streaming SSR
- SSR Cancellation
- RSC Integration
- Testing
- Glossary
Tree-shakeable functions — import only what you need.
- add (addRoute)
- remove (removeRoute)
- replace (replaceRoutes)
- clear (clearRoutes)
- get (getRoute)
- has (hasRoute)
- update (updateRoute)
- get (getDependency)
- getAll (getDependencies)
- set (setDependency)
- setAll (setDependencies)
- has (hasDependency)
- remove (removeDependency)
- reset (resetDependencies)
For plugin authors, not for general use.
- makeState
- buildState
- buildNavigationState
- forwardState
- getForwardState
- setForwardState
- matchPath
- setRootPath
- getRootPath
- navigateToState
- addEventListener
- getRouteConfig
- getOptions
- addBuildPathInterceptor
- extendRouter
- getTree
- React Integration Guide
- Preact Integration Guide
- Solid Integration Guide
- Vue Integration Guide
- Svelte Integration Guide
- Ink (Terminal UI) Integration Guide
- Desktop (Electron, Tauri) Integration Guide
- useRouter
- useRoute
- useRouteNode
- useNavigator
- useRouteUtils
- useRouterTransition
- useRouteExit
- useRouteEnter
- useRouteStore (Solid only)
- useRouteNodeStore (Solid only)
SSR-feature subpath —
@real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.
- Lazy (Svelte only — dynamic component import)
- Await — read deferred SSR data by key
- Streamed — Suspense-style boundary
- ClientOnly — server fallback → client children swap
- ServerOnly — server children, removed after hydration
- HttpStatusCode — render-time HTTP status declaration
-
HttpStatusProvider — provides sink to descendant
<HttpStatusCode> - useDeferred — read deferred Promise by key