Skip to content

React Integration

olegivanov edited this page Jun 2, 2026 · 6 revisions

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-plugin

Peer 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 /ink entry only, optional)

For other frameworks, see Preact · Solid · Vue · Svelte · Angular · Ink (terminal UI).


Quick Start

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>;
}

Entry Points

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 /legacy for client API + /legacy/ssr for SSR helpers.
  • Building a CLI / terminal UI? → use /ink. See Ink Integration.
  • New project or already on React 19.2+? → main entry + /ssr as 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.


Key Features

Native useSyncExternalStore

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.

<Activity> for keepAlive (React 19.2+)

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.

Memoized <Link>

Link uses React.memo() with a custom areLinkPropsEqual comparator:

  • Primitive props (routeName, className, etc.) compared via Object.is.
  • routeParams / routeOptions compared via shallowEqual (Object.is per key, key-order insensitive).
  • 99% of Links pass routeParams=undefined and hit the Object.is(undefined, undefined) fast path.

Nested objects in params are not deep-compared — stabilize via useMemo if needed.

Hook caching via @real-router/sources

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.


useSyncExternalStore — Native Foundation

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: Element Type Markers

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.


Build: tsdown, Multi-Entry

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.


Complete API Reference

Provider

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>

Hooks

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

Components (main entry — @real-router/react)

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

SSR-feature components / hooks (@real-router/react/ssr + /legacy/ssr)

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.

Types (exported from @real-router/react)

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


Patterns

Reading Route Parameters

function UserProfile() {
  const { route } = useRouteNode("users.profile");
  return <h1>User: {route?.params.id}</h1>;
}

Typed Route Parameters

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.

Conditional Rendering

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>;
}

Imperative Navigation

function BackButton() {
  const navigator = useNavigator();
  return <button onClick={() => navigator.navigate("home")}>Back</button>;
}

Transition Progress Bar

function ProgressBar() {
  const { isTransitioning, isLeaveApproved } = useRouterTransition();
  if (!isTransitioning) return null;
  return (
    <div
      className={isLeaveApproved ? "progress activating" : "progress leaving"}
      aria-live="polite"
    >
      Navigating...
    </div>
  );
}

Selective Re-Rendering via useRouteNode

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;
}

Stabilizing Params for <Link>

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.


Lazy Loading with Suspense

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.


RouterErrorBoundary

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.


SSR

No built-in SSR support. For SSR:

  • Create router per request (don't share across requests).
  • Initialize with the matched URL (router.start(url)).
  • previousRoute will be undefined on server.

See the examples/react/ssr directory for a full Express + React 19 setup.


Performance

  • useRouteNode uses cached createRouteNodeSource from @real-router/sources — one subscription per (router, nodeName) shared across all consumers.
  • useRouterTransition uses getTransitionSource — shared eager source per router.
  • RouterErrorBoundary uses createDismissableError — shared error source with integrated dismissal state.
  • useIsActiveRoute uses cached createActiveRouteSource — params hashed via canonicalJson (key-order insensitive).
  • Link uses memo() with custom areLinkPropsEqual — primitive compare + shallowEqual for routeParams/routeOptions.
  • All WeakMap caches live in @real-router/sources — auto-evicted on router GC.

See Also

Navigation

Home


Concepts


Getting Started


Router Methods

Lifecycle

Navigation

State

URL & Path

Events


Standalone API

Tree-shakeable functions — import only what you need.

Routes — getRoutesApi(router)

Dependencies — getDependenciesApi(router)

Guards — getLifecycleApi(router)

Plugin Infrastructure — getPluginApi(router)

For plugin authors, not for general use.

SSR / SSG


React / Preact / Solid / Vue / Svelte Integration

Provider

Hooks

Components

SSR Components & Hooks

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

DOM Utilities

Patterns


Subscription Layer (@real-router/sources)


Reactive Streams (@real-router/rx)


Plugins

Browser Plugin

Navigation Plugin

Hash Plugin

Memory Plugin

Lifecycle Plugin

Preload Plugin

Logger Plugin

Persistent Params

SSR Data

RSC Server

Validation

Search Schema

Utilities


Reference

Types

Error Codes

Clone this wiki locally