npm install --save-prod react-corsair
🧭 Routing
- Router and routes
- Route params
- Pathname templates
- Outlets
- Nested routes
- Code splitting
- Data loading
- Error boundaries
- Not found
- Redirects
- Prefetching
- Route interception
- Inline routes
🔗 History
- Rendering disposition
- Render to string
- Streaming SSR
- State serialization
- Content-Security-Policy support
🍪 Cookbook
URLs don't matter because they are almost never part of the application domain logic. React Corsair is a router that abstracts URLs away from your application domain.
Use Route
objects instead of
URLs to match locations, validate params, navigate between pages, prefetch data, infer types, etc.
React Corsair can be used in any environment and doesn't require any browser-specific API to be available. While history integration is optional, it is available out-of-the-box if you need it.
To showcase how the router works, lets start by creating a page component:
function HelloPage() {
return 'Hello';
}
Create a route that maps a URL pathname to a page component:
import { createRoute } from 'react-corsair';
const helloRoute = createRoute('/hello', HelloPage);
Now we need a Router
that would
handle the navigation:
import { Router } from 'react-corsair';
const router = new Router({ routes: [helloRoute] });
To let the router know what route to render, call
navigate
:
router.navigate(helloRoute);
Use <RouterProvider>
to render the router:
import { RouterProvider } from 'react-corsair';
function MyApp() {
return <RouterProvider value={router}/>;
}
And that's how you render your first route with React Corsair!
Routes are navigation entry points. Most routes associate a pathname with a rendered component:
import { createRoute } from 'react-corsair';
function HelloPage() {
return 'Hello';
}
const helloRoute = createRoute('/hello', HelloPage);
In this example we used a shorthand signature of
the createRoute
function.
You can also use
a route options object:
const helloRoute = createRoute({
pathname: '/hello',
component: HelloPage
});
Routes are location providers:
helloRoute.getLocation();
// ⮕ { pathname: '/hello', searchParams: {}, hash: '', state: undefined }
Routes are matched during router navigation:
import { Router } from 'react-corsair';
const router = new Router({ routes: [helloRoute] });
router.navigate(helloRoute);
Use a location to navigate a router:
router.navigate({ pathname: '/hello' });
To trigger navigation from inside a component, use the
useRouter
hook:
function AnotherPage() {
const router = useRouter();
const handleClick = () => {
router.navigate(helloRoute);
};
return <button onClick={handleClick}>{'Go to hello'}</button>;
}
Routes can be parameterized with pathname params and search params. Let's create a route that has a pathname param:
const productRoute = createRoute<{ sku: number }>('/products/:sku', ProductPage);
Router cannot create a location for a parameterized route by itself, because it doesn't know the required param values.
So here's where
getLocation
comes handy:
const productLocation = productRoute.getLocation({ sku: 42 });
// ⮕ { pathname: '/products/42', searchParams: {}, hash: '', state: undefined }
router.navigate(productLocation);
Read more about pathname params syntax in the Pathname templates section.
By default, params that aren't a part of a pathname become search params:
- const productRoute = createRoute<{ sku: number }>('/products/:sku', ProductPage);
+ const productRoute = createRoute<{ sku: number }>('/products', ProductPage);
sku
is now a search param:
productRoute.getLocation({ sku: 42 });
// ⮕ { pathname: '/products', searchParams: { sku: 42 }, hash: '', state: undefined }
You can have both pathname and search params on the same route:
interface ProductParams {
sku: number;
color: 'red' | 'green';
}
const productRoute = createRoute<ProductParams>('/products/:sku', ProductPage);
productRoute.getLocation({ sku: 42, color: 'red' });
// ⮕ { pathname: '/products/42', searchParams: { color: 'red' }, hash: '', state: undefined }
To access params from a component use the
useRoute
hook:
function ProductPage() {
const { params } = useRoute(productRoute);
// ⮕ { sku: 42, color: 'red' }
}
Provide params adapter to parse route params:
const userRoute = createRoute({
pathname: '/users/:userId',
paramsAdapter: params => {
return { userId: params.userId };
}
});
Note that we didn't specify parameter types explicitly this time: TypeScript can infer them from the
paramsAdapter
.
Use your favourite validation library to parse and validate params:
import * as d from 'doubter';
const productRoute = createRoute({
pathname: '/products/:sku',
paramsAdapter: d.object({
sku: d.number().int().nonNegative().coerce(),
color: d.enum(['red', 'green']).optional()
})
});
productRoute.getLocation({ sku: 42, color: 'red' });
Tip
Read more about Doubter, the runtime validation and transformation library.
A pathname provided for a route is parsed as a pattern. Pathname patterns may contain named params and matching flags.
Pathname patterns are compiled into
a PathnameTemplate
when
route is created. A template allows to both match a pathname, and build a pathname using a provided set of params.
After a route is created, you can access a pathname pattern like this:
const productsRoute = createRoute('/products');
productsRoute.pathnameTemplate.pattern;
// ⮕ '/products'
By default, a pathname pattern is case-insensitive. So the route in example above would match both /products
and
/PRODUCTS
.
If you need a case-sensitive pattern, provide
isCaseSensitive
route option:
createRoute({
pathname: '/products',
isCaseSensitive: true
});
Pathname patterns can include params that conform :[A-Za-z$_][A-Za-z0-9$_]+
:
const userRoute = createRoute('/users/:userId');
You can retrieve param names at runtime:
userRoute.pathnameTemplate.paramNames;
// ⮕ Set { 'userId' }
Params match a whole segment and cannot be partial.
createRoute('/users__:userId');
// ❌ SyntaxError
createRoute('/users/:userId');
// ✅ Success
By default, a param matches a non-empty pathname segment. To make a param optional (so it can match an absent
segment) follow it by a ?
flag.
createRoute('/product/:sku?');
This route matches both /product
and /product/37
.
Static pathname segments can be optional as well:
createRoute('/shop?/product/:sku');
This route matches both /shop/product/37
and /product/37
.
By default, a param matches a single pathname segment. Follow a param with a *
flag to make it match multiple
segments.
createRoute('/:slug*');
This route matches both /watch
and /watch/a/movie
.
To make param both wildcard and optional, combine *
and ?
flags:
createRoute('/:slug*?');
To use :
as a character in a pathname pattern, replace it with
an encoded
representation %3A
:
createRoute('/foo%3Abar');
Route components are rendered inside
an <Outlet>
. If you don't provide
children to
<RouterProvider>
then it
would implicitly render an <Outlet>
:
import { Router, RouterProvider } from 'react-corsair';
function HelloPage() {
return 'Hello';
}
const helloRoute = createRoute('/hello', HelloPage);
const router = new Router({ routes: [helloRoute] });
router.navigate(helloRoute);
function App() {
return <RouterProvider value={router}/>;
}
You can provide children to <RouterProvider>
:
function App() {
return (
<RouterProvider value={router}>
<main>
<Outlet/>
</main>
</RouterProvider>
);
}
The rendered output would be:
<main>Hello</main>
Routes can be nested:
const parentRoute = createRoute('/parent', ParentPage);
const childRoute = createRoute(parentRoute, '/child', ChildPage);
childRoute.getLocation();
// ⮕ { pathname: '/parent/child', searchParams: {}, hash: '', state: undefined }
Routes are rendered inside outlets, so ParentPage
should
render an <Outlet>
to give place for
a ChildPage
:
function ParentPage() {
return (
<section>
<Outlet/>
</section>
);
}
function ChildPage() {
return <em>{'Hello'}</em>;
}
To allow router navigation to childRoute
it should be listed among
routes
:
const router = new Router({ routes: [childRoute] });
router.navigate(childRoute);
The rendered output would be:
<section><em>Hello</em></section>
If you create a route without specifying a component, it would render an <Outlet>
by default:
- const parentRoute = createRoute('/parent', ParentPage);
+ const parentRoute = createRoute('/parent');
Now the rendering output would be:
<em>Hello</em>
To enable code splitting in your app, use the
lazyComponent
option, instead of the
component
:
const userRoute = createRoute({
pathname: '/user',
lazyComponent: () => import('./UserPage.js')
});
Default-export the component from the ./UserPage.js
:
export default function UserPage() {
return 'Hello';
}
When router is navigated to the userRoute
, a module that contains <UserPage>
is loaded and rendered. The loaded
component is cached, so next time the userRoute
is matched, <UserPage>
would be rendered instantly.
A promise is thrown if the lazyComponent
isn't loaded yet. You can wrap
<RouterProvider>
in
a custom <Suspense>
boundary to catch it and render a fallback:
function LoadingIndicator() {
return 'Loading';
}
<Suspense fallback={<LoadingIndicator/>}>
<RouterProvider value={router}/>
</Suspense>
Or you can to provide a
loadingComponent
option to your route, so an <Outlet>
renders a <Suspense>
for you, using loadingComponent
as a fallback:
const userRoute = createRoute({
pathname: '/user',
lazyComponent: () => import('./UserPage.js'),
loadingComponent: LoadingIndicator
});
Now, loadingComponent
would be rendered if there's loading in progress.
Each route may have a custom loading component: here you can render a page skeleton or a spinner.
Router can render the previously matched route when a new route is being loaded, even if a new route has
a loadingComponent
. Customize this behavior by adding a
loadingAppearance
option:
const userRoute = createRoute({
pathname: '/user',
lazyComponent: () => import('./UserPage.js'),
loadingComponent: LoadingIndicator,
loadingAppearance: 'loading'
});
This tells a router to always render userRoute.loadingComponent
when userRoute
is matched and lazy component isn't
loaded yet. loadingAppearance
can be set to:
- "loading"
-
Always render
loadingComponent
if a route requires loading. - "route_loading"
-
Render
loadingComponent
only if a route is changed during navigation. This is the default behavior. - "avoid"
-
If there's a route that is already rendered then keep it on the screen until the new route is loaded.
If an error is thrown during lazyComponent
loading, an error boundary is rendered and router
would retry loading the component again later.
Routes may require some data to render. Triggering data loading during rendering may lead to a waterfall. React Corsair provides an easy way to load route data ahead of rendering:
function LoadingIndicator() {
return 'Loading';
}
const userRoute = createRoute<{ userId: string }, User>({
pathname: '/users/:userId',
component: UserPage,
loadingComponent: LoadingIndicator,
dataLoader: async options => {
const response = await fetch('/api/users/' + options.params.userId);
return response.json();
// ⮕ Promise<User>
}
});
dataLoader
is called every
time router is navigated to userRoute
. While data is being loaded, the <LoadingIndicator>
is rendered instead of
the <UserPage>
.
You can access the loaded data in your route component using
the useRoute
hook:
function UserPage() {
const { data } = useRoute(userRoute);
// ⮕ User
}
Data loader may require additional context:
const userRoute = createRoute<{ userId: string }, User, { apiBase: string }>({
pathname: '/users/:userId',
component: UserPage,
loadingComponent: LoadingIndicator,
dataLoader: async options => {
const response = await fetch(options.context.apiBase + '/users/' + options.params.userId);
return response.json();
}
});
A context value should be provided through a router:
const router = new Router({
routes: [userRoute],
context: {
apiBase: 'https://superpuper.com'
}
});
Each route is rendered in its own
error boundary. If an
error occurs during route component rendering or data loading,
then an errorComponent
is rendered as a fallback:
function UserPage() {
throw new Error('Ooops!');
}
function ErrorDetails() {
return 'An error occurred';
}
const userRoute = createRoute({
pathname: '/user',
component: UserPage,
errorComponent: ErrorDetails
});
You can access the error that triggered the error boundary within an error component:
import { useRoute } from 'react-corsair';
function ErrorDetails() {
const { error } = useRoute(userRoute);
return 'An error occurred: ' + error.message;
}
Some errors are recoverable and only require a route data to be reloaded:
function ErrorDetails() {
const routeController = useRoute(userRoute);
const handleClick = () => {
routeController.load();
};
return <button onClick={handleClick}>{'Reload'}</button>;
}
Clicking on a "Reload" button would reload the route data and component (if needed).
You can trigger a route error from an event handler:
function UserPage() {
const routeController = useRoute(userRoute);
const handleClick = () => {
routeController.setError(new Error('Ooops!'));
};
return <button onClick={handleClick}>{'Show error'}</button>;
}
During route component rendering, you may detect that there's not enough data to render a route. Call
the notFound
in such case:
import { notFound, useRoute } from 'react-corsair';
function ProductPage() {
const { params } = useRoute(userRoute);
const user = getProductById(params.sku);
// ⮕ User | null
if (user === null) {
// 🟡 Aborts further rendering
notFound();
}
return 'Hello, ' + user.firstName;
}
notFound
throws aborts further rendering and causes router to render
a notFoundComponent
as a fallback:
function ProductNotFound() {
return 'Product not found';
}
const productRoute = createRoute<{ sku: string }>({
pathname: '/products/:sku',
component: ProductPage,
notFoundComponent: ProductNotFound
});
You can call notFound
from a data loader as well:
const productRoute = createRoute<{ sku: string }>({
pathname: '/products/:sku',
component: ProductPage,
notFoundComponent: ProductNotFound,
dataLoader: async () => {
// Try to load product here or call notFound
notFound();
}
});
Force router to render notFoundComponent
from an event handler:
function ProductPage() {
const routeController = useRoute(productRoute);
const handleClick = () => {
routeController.notFound();
};
return <button onClick={handleClick}>{'Render not found'}</button>
}
During route component rendering, you can trigger a redirect by calling
redirect
:
import { createRoute, redirect } from 'react-corsair';
function AdminPage() {
redirect(loginRoute);
}
const adminRoute = createRoute('/admin', AdminPage);
When redirect
is called during rendering, router would render a
loadingComponent
.
redirect
accepts routes, locations,
and URL strings as an argument. Rect Corsair doesn't have a default behavior for redirects. Use a router event listener
to handle redirects:
const router = new Router({ routes: [adminRoute] });
router.subscribe(event => {
if (event.type !== 'redirect') {
return;
}
if (typeof event.to === 'string') {
window.location.href = event.to;
return;
}
router.navigate(event.to);
});
Sometimes you know ahead of time that a user would visit a particular route, and you may want to prefetch the component and related data so the navigation is instant.
To do this, call
the Router.prefetch
method and provide a route or a location to prefetch. Router would load required components
and trigger data loaders:
router.prefetch(productRoute);
If a route requires params, use
getLocation
to create
a prefetched location:
router.prefetch(user.getLocation({ userId: 42 }));
Use Prefetch
component for a
more declarative route prefetching:
<Prefetch to={productRoute}/>
React Corsair triggers required data loaders on every navigation, so you may need to implement caching for data loaders.
When a router is navigated to a new location, a target route can be intercepted and rendered in the layout of the current route. This can be useful when you want to display the content of a route without the user switching to a different context.
To showcase how to use route interception, let's start with creating create a shop feed from which products can be opened in a separate page.
Here's the product route and its component:
import { createRoute, useRoute } from 'react-corsair';
const productRoute = createRoute<{ sku: number }>('/product/:sku', ProductPage);
function ProductPage() {
const { params } = useRoute(productRoute);
// Render a product here
}
Shop feed is a list of product links:
import { createRoute } from 'react-corsair';
import { Link } from 'react-corsair/history';
const shopRoute = createRoute('/shop', ShopPage);
function ShopPage() {
return <Link to={productRoute.getLocation(42)}>{'Go to product'}</Link>;
}
Setup the history and the router:
import { Router } from 'react-corsair';
import { createBrowserHistory } from 'react-corsair/history';
const history = createBrowserHistory();
const router = new Router({ routes: [shopRoute, productRoute] });
// 🟡 Trigger router navigation if history location changes
history.subscribe(() => {
router.navigate(history.location);
});
Render the router:
import { RouterProvider } from 'react-corsair';
import { HistoryProvider } from 'react-corsair/history';
<HistoryProvider value={history}>
<RouterProvider value={router}/>
</HistoryProvider>
Now when user opens /shop
and clicks on Go to product, the browser location changes to /product/42
and
the productRoute
is rendered.
With route interception we can render productRoute
route inside the <ShopPage>
, so the browser location would be
/product/42
and the user would see the shop feed with a product inlay.
To achieve this, add
the useInterceptedRoute
hook to <ShopPage>
:
import { useInterceptedRoute } from 'react-corsair';
function ShopPage() {
const productController = useInterceptedRoute(productRoute);
// ⮕ RouteController | null
return (
<>
<Link to={productRoute.getLocation(42)}>{'Go to product'}</Link>
{productController !== null && <RouteOutlet controller={productController}/>}
</>
);
}
Now when user clicks on Go to product, the browser location changes to /product/42
and <ShopPage>
is re-rendered.
productController
would contain
a route controller for
productRoute
. This controller can be then rendered using
the <RouteOutlet>
.
If a user clicks the Reload button in the browser, a <ProductPage>
would be rendered because it matches
/product/42
.
You can render <RouteOutlet>
in a popup to show the product preview, allowing user not to loose the context of
the shop feed.
Use
cancelInterception
method to render the intercepted route in a router <Outlet>
:
router.cancelInterception();
Inline routes allow rendering a route that matches a location inside a component:
import { useInlineRoute, RouteOutlet } from 'react-corsair';
function Product() {
const productController = useInlineRoute(productRoute.getLocation(42));
return productController !== null && <RouteOutlet controller={productController}/>;
}
useInlineRoute
matches the
provided location against routes of the current router and returns a corresponding route controller.
React Corsair provides a seamless history integration:
import { Router, RouterProvider, userRoute } from 'react-corsair';
import { createBrowserHistory, HistoryProvider } from 'react-corsair/history';
const history = createBrowserHistory();
const router = new Router({ routes: [helloRoute] });
// 1️⃣ Trigger router navigation if history location changes
history.subscribe(() => {
router.navigate(history.location);
});
// 2️⃣ Trigger history location change if redirect is dispatched
router.subscribe(event => {
if (event.type === 'redirect') {
history.replace(event.to);
}
});
function App() {
return (
// 5️⃣ Provide history to components
<HistoryProvider value={history}>
<RouterProvider value={router}/>
</HistoryProvider>
);
}
Inside components use useHistory
hook to retrieve the provided History
:
const history = useHistory();
Push and replace routes using history:
history.push(helloRoute);
history.replace(productRoute.getLocation({ sku: 42 }));
There are three types of history adapters that you can leverage:
-
createBrowserHistory
is a DOM-specific history adapter, useful in web browsers that support the HTML5 history API. -
createHashHistory
is a DOM-specific history adapter that stores location in a URL hash. -
createMemoryHistory
is an in-memory history adapter, useful in testing and non-DOM environments like SSR.
History provides two types of URL strings:
-
Local URLs can be used as arguments for push and replace methods.
-
Absolute URLs reflect
window.location.href
.
All history adapters produce local URLs in the same way:
const helloRoute = createRoute('/hello');
history.toURL(helloRoute);
// ⮕ '/hello'
But absolute URLs are produced differently:
createBrowserHistory().toAbsoluteURL(helloRoute);
// ⮕ '/hello'
createHashHistory().toAbsoluteURL(helloRoute);
// ⮕ '#/hello'
createMemoryHistory(['/']).toAbsoluteURL(helloRoute);
// ⮕ '/hello'
A basePathname
can be prepended to an absolute URL:
createBrowserHistory({ basePathname: '/wow' }).toAbsoluteURL(helloRoute);
// ⮕ '/wow/hello'
createHashHistory({ basePathname: '/wow' }).toAbsoluteURL(helloRoute);
// ⮕ '/wow#/hello'
When history serializes a URL, it uses an adapter to stringify search params:
const helloRoute = createRoute<{ color: string }>('/hello');
history.toURL(helloRoute.getLocation({ color: 'red' }));
// ⮕ '/hello?color=red'
By default, history serializes
search params with
jsonSearchParamsSerializer
which serializes individual params with
JSON
:
interface ShopParams {
pageIndex: number;
categories: string[];
sortBy: 'price' | 'rating';
available: boolean;
}
const shopRoute = createRoute<ShopParams>('/shop');
history.toURL(helloRoute.getLocation({
pageIndex: 3,
categories: ['electronics', 'gifts'],
sortBy: 'price',
available: true
}));
// ⮕ '/shop?pageIndex=3&categories=%5B%22electronics%22%2C%22gifts%22%5D&sortBy=price&available=true'
jsonSearchParamsSerializer
allows you to store complex data structures in a URL.
You can create
a custom search params adapter
and provide it to a history. Here's how to create
a basic adapter that uses URLSearchParams
:
createBrowserHistory({
searchParamsSerializer: {
parse: search => Object.fromEntries(new URLSearchParams(search)),
stringify: params => new URLSearchParams(params).toString(),
}
});
Inside components use <Link>
for navigation:
import { Link } from 'react-corsair/history';
function FavouritesPage() {
return (
<Link to={productRoute.getLocation({ sku: 42 })}>
{'Go to a product 42'}
</Link>
);
}
Links can automatically prefetch a route component and related data:
<Link
to={productRoute.getLocation({ sku: 42 })}
isPrefetched={true}
>
{'Go to a product 42'}
</Link>
Navigation blocking is a way to prevent navigation from happening. This is typical if a user attempts to navigate while there are unsaved changes. Usually, in such situation, a prompt or a custom UI should be shown to the user to confirm the navigation.
Show a browser confirmation popup to the user:
useHistoryBlocker(() => {
return hasUnsavedChanges && !confirm('Discard unsaved changes?')
});
With proceed
and
cancel
you can
handle a navigation transaction in an asynchronous manner:
useHistoryBlocker(transaction => {
if (!hasUnsavedChanges) {
// No unsaved changes, proceed with the navigation
transaction.proceed();
return;
}
if (!confirm('Discard unsaved changes?')) {
// User decided to keep unsaved changes
transaction.cancel();
}
});
Ask user to confirm the navigation only if there are unsaved changes:
const transaction = useHistoryBlocker(() => hasUnsavedChanges);
// or
// const transaction = useHistoryBlocker(hasUnsavedChanges);
transaction && (
<dialog open={true}>
<p>{'Discard unsaved changes?'}</p>
<button onClick={transaction.proceed}>{'Discard'}</button>
<button onClick={transaction.cancel}>{'Cancel'}</button>
</dialog>
)
Always ask user to confirm the navigation:
const transaction = useHistoryBlocker();
Routes can be rendered on the server side and then hydrated on the client side.
To enable hydration on the client, create
a Router
and call
hydrateRouter
instead of
Router.navigate
:
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { createBrowserHistory, HistoryProvider } from 'react-router/history';
import { hydrateRouter, Router, RouterProvider } from 'react-router';
const history = createBrowserHistory();
const router = new Router({ routes: [helloRoute] });
// 🟡 Start router hydration instead on navigating
hydrateRouter(router, history.location);
hydrateRoot(
document,
<HistoryProvider value={history}>
<RouterProvider value={router}/>
</HistoryProvider>
);
Important
The location passed to hydrateRouter
and set of routes passed to the Router
must be the same as ones used during
the server-side rendering. Otherwise, hydration would have undefined behavior.
hydrateRouter
must be called only once, and only one router on the client side can receive the dehydrated state from the server.
On the server, you can either render your app contents as a string and send it to the client in one go, or stream the contents.
By default, during when SSR is used all routes are rendered both on the server side and on the client side. You can
prevent server-side rendering for a route by specifying
the renderingDisposition
option:
const helloRoute = createRoute({
pathname: '/hello',
component: HelloPage,
renderingDisposition: 'client'
});
Now helloRoute
is rendered on the client-side only.
Rendering disposition can be set to:
- "server"
- Route is rendered on the server during SSR and hydrated on the client.
- "client"
- Route is rendered on the client. Loading state is rendered on the server during SSR.
Use SSRRouter
to render your app as an HTML
string:
import { createServer } from 'http';
import { renderToString } from 'react-dom/server';
import { RouterProvider } from 'react-corsair';
import { createMemoryHistory, HistoryProvider } from 'react-corsair/history';
import { SSRRouter } from 'react-corsair/ssr';
const server = createServer(async (request, response) => {
// 1️⃣ Create a new history and a new router for each request
const history = createMemoryHistory([request.url]);
const router = new SSRRouter({ routes: [helloRoute] });
// 2️⃣ Navigate router to a requested location
router.navigate(history.location);
let html;
// 3️⃣ Re-render until there are no more changes
while (await router.hasChanges()) {
html = renderToString(
<HistoryProvider value={history}>
<RouterProvider value={router}/>
</HistoryProvider>
);
}
// 4️⃣ Attach dehydrated route states
html += router.nextHydrationChunk();
// 5️⃣ Send the rendered HTML to the client
response.end(html);
});
server.listen(8080);
You may also need to attach the chunk with your application code:
html += '<script src="/client.js" async></script>';
A new router and a new history must be created for each request, so the results that are stored in router are served in response to a particular request.
hasChanges
would
resolve with true
if state of some routes have changed during rendering.
The hydration chunk returned
by nextHydrationChunk
contains the <script>
tag that hydrates the router for which
hydrateRouter
is invoked on the client side.
React can stream parts of your app while it is being rendered. React Corsair provides API to inject its hydration chunks into a streaming process. The API is different for NodeJS streams and Readable Web Streams.
In NodeJS environment
use PipeableSSRRouter
import { createServer } from 'http';
import { renderToPipeableStream } from 'react-dom/server';
import { RouterProvider } from 'react-corsair';
import { createMemoryHistory, HistoryProvider } from 'react-corsair/history';
import { PipeableSSRRouter } from 'react-corsair/ssr/node';
const server = createServer((request, response) => {
// 1️⃣ Create a new history and a new router for each request
const history = createMemoryHistory([request.url]);
const router = new PipeableSSRRouter(response, { routes: [helloRoute] });
// 2️⃣ Navigate router to a requested location
router.navigate(history.location);
const stream = renderToPipeableStream(
<HistoryProvider value={history}>
<RouterProvider value={router}/>
</HistoryProvider>,
{
bootstrapScripts: ['/client.js'],
onShellReady() {
// 3️⃣ Pipe the rendering output to the router's stream
stream.pipe(router.stream);
},
}
);
});
server.listen(8080);
Router hydration chunks are streamed to the client along with chunks rendered by React.
To enable streaming in a modern environment,
use ReadableSSRRouter
import { createServer } from 'http';
import { renderToPipeableStream } from 'react-dom/server';
import { RouterProvider } from 'react-corsair';
import { createMemoryHistory, HistoryProvider } from 'react-corsair/history';
import { ReadableSSRRouter } from 'react-corsair/ssr';
async function handler(request) {
// 1️⃣ Create a new history and a new router for each request
const history = createMemoryHistory([request.url]);
const router = new ReadableSSRRouter({ routes: [helloRoute] });
// 2️⃣ Navigate router to a requested location
router.navigate(history.location);
const stream = await renderToReadableStream(
<HistoryProvider value={history}>
<RouterProvider value={router}/>
</HistoryProvider>,
{
bootstrapScripts: ['/client.js'],
}
);
// 3️⃣ Pipe the response through the router
return new Response(stream.pipeThrough(router), {
headers: { 'content-type': 'text/html' },
});
}
Router hydration chunks are streamed to the client along with chunks rendered by React.
By default, route state is serialized using
JSON.stringify
which has quite a few limitations. If your route loads data that may contain circular references,
or non-serializable data like BigInt
, use a custom state serialization.
On the client, pass
a stateParser
option to hydrateRouter
:
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { createBrowserHistory, HistoryProvider } from 'react-router/history';
import { hydrateRouter, Router, RouterProvider } from 'react-router';
import JSONMarshal from 'json-marshal';
const history = createBrowserHistory();
const router = new Router({ routes: [helloRoute] });
hydrateRouter(router, history.location, {
// 🟡 Pass a custom state parser
stateParser: JSONMarshal.parse
});
hydrateRoot(
document,
<HistoryProvider value={history}>
<RouterProvider value={router}/>
</HistoryProvider>
);
On the server, pass
a stateStringifier
option to SSRRouter
,
PipeableSSRRouter
,
or ReadableSSRRouter
, depending on your setup:
import { ReadableSSRRouter } from 'react-corsair/ssr';
import JSONMarshal from 'json-marshal';
const router = new ReadableSSRRouter({
routes: [helloRoute],
stateStringifier: JSONMarshal.stringify
});
Tip
Read more about JSON Marshal, it can stringify and parse any data structure.
By default,
nextHydrationChunk
renders an inline <script>
tag without any attributes. To enable the support of
the script-src
directive of the Content-Security-Policy
header, provide
the nonce
option
to SSRRouter
or any of its subclasses:
const router = new SSRRouter({
routes: [helloRoute],
nonce: '2726c7f26c'
});
Send the header with this nonce in the server response:
Content-Security-Policy: script-src 'nonce-2726c7f26c'
Route masking allows you to render a different route than one that was matched by the history.
Router is navigated by history changes:
history.subscribe(() => {
router.navigate(history.location);
});
User navigates to a /foo
location:
history.push('/foo');
You can intercept the router navigation before it is rendered (and before data loaders are triggered) and supersede the navigation:
router.subscribe(event => {
if (event.type === 'navigate' && event.location.pathname === '/foo') {
router.navigate(barRoute);
}
});
Now regardless of what route was matched by /foo
, router would render barRoute
.
This technique can be used to render a login page whenever the non-authenticated user tries to reach a page that requires login. Here's how to achieve this:
const adminRoute = createRoute('/admin', AdminPage);
const loginPage = createRoute('/login', LoginPage);
// A set of routes that require user to be logged in
const privateRoutes = new Set([adminRoute]);
// User status provided by your application
const isLoggedIn = false;
router.subscribe(event => {
if (
!isLoggedIn &&
event.type === 'navigate' &&
event.controller !== null &&
privateRoutes.has(event.controller.route)) {
router.navigate(loginPage);
}
});
❤️