react-corsair
v0.0.11
Published
Because routes are better then URLs.
Maintainers
Readme
Type-safe router that abstracts URLs away.
- TypeScript first: type-safe path and query parameters.
- Code splitting, data loading and prefetching out-of-the box.
- Route interception and inline routes.
- Expressive and concise API with strict typings.
- Supports SSR, partial pre-rendering and Suspense.
- Optional history integration.
- Just 9 kB gzipped. ↗
- Check out the Cookbook for real-life examples!
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
Routing
URLs don't matter, 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. Usually, a route declaration this is the only place where you would meet a pathname:
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!
Router and routes
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>;
}If you want the browser history to drive your navigation, see the History section.
Route params
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.
Pathname templates
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. Pathname param names should 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');
// ✅ SuccessBy 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');Outlets
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>Nested routes
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>Code splitting
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 manually 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: 'always',
});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:
Always render loadingComponent if a route requires loading.
Render loadingComponent only if a route is changed during navigation. This is the default behavior.
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 during the next navigation.
Data loading
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 productRoute = createRoute<{ sku: string }, User>({
pathname: '/products/:sku',
component: ProductPage,
loadingComponent: LoadingIndicator,
dataLoader: async options => {
const response = await fetch('/api/products/' + options.params.sku);
return response.json();
// ⮕ Promise<Product>
},
});dataLoader ↗
is called every time the router is navigated to productRoute. While data is being loaded, the <LoadingIndicator>
is rendered instead of the <ProductPage>.
You can access the loaded data in your route component using
the useRoute ↗
hook:
function ProductPage() {
const { data } = useRoute(productRoute);
// ⮕ Product
}Data loader may require additional context:
const productRoute = createRoute<{ sku: string }, Product, { apiBase: string }>({
pathname: '/products/:sku',
component: ProductPage,
loadingComponent: LoadingIndicator,
dataLoader: async options => {
// 🟡 Access the router context in a data loader
const { apiBase } = options.router.context;
const response = await fetch(apiBase + '/products/' + options.params.sku);
return response.json();
},
});A context value should be provided through a router:
const router = new Router({
routes: [productRoute],
context: {
apiBase: 'https://superpuper.com',
},
});Error boundaries
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 ProductsPage() {
throw new Error('Ooops!');
}
function ErrorDetails() {
return 'An error occurred';
}
const productsRoute = createRoute({
pathname: '/products',
component: ProductsPage,
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(productsRoute);
return 'An error occurred: ' + error.message;
}Some errors are recoverable and only require a route data or component to be reloaded ↗:
function ErrorDetails() {
const productsRouteController = useRoute(productsRoute);
const handleClick = () => {
productsRouteController.reload();
};
return <button onClick={handleClick}>{'Reload'}</button>;
}Clicking on a "Reload" button would reload the route data and component (if it wasn't successfully loaded before).
You can trigger a route error from an event handler:
function ProductsPage() {
const productsRouteController = useRoute(productsRoute);
const handleClick = () => {
productsRouteController.setError(new Error('Ooops!'));
};
return <button onClick={handleClick}>{'Show error'}</button>;
}Not found
During route component rendering, you may detect that there's not enough data to render a route. Call
the notFound ↗
during rendering in such case:
import { notFound, useRoute } from 'react-corsair';
function ProductPage() {
const { params } = useRoute(productRoute);
const product = getProductById(params.sku);
// ⮕ Product | null
if (product === null) {
// 🟡 No product was found, abort further rendering
notFound();
}
return 'The product title is ' + product.title;
}notFound throws
the NotFoundError ↗
symbol and aborts further rendering of the route component. The <Outlet> catches NotFoundError and renders
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: () => {
// 🟡 Try to load product here or call notFound
notFound();
},
});Force router to render notFoundComponent from an event handler:
function ProductPage() {
const productRouteController = useRoute(productRoute);
const handleClick = () => {
// 🟡 Force Outlet to render the notFoundComponent
productRouteController.notFound();
};
return <button onClick={handleClick}>{'Render not found'}</button>;
}Redirects
Trigger redirect during data loading or during rendering.
Call redirect ↗
during rendering:
import { createRoute, redirect } from 'react-corsair';
function AdminPage() {
if (!isAdmin) {
redirect(loginRoute);
}
}
const adminRoute = createRoute('/admin', AdminPage);Or call redirect from a data loader:
function AdminPage() {
// isAdmin is true during rendering
}
const adminRoute = createRoute({
pathname: '/admin',
component: AdminPage,
// 🟡 A redirect is thrown before rendering begins
dataLoader: () => {
if (!isAdmin) {
redirect(loginRoute);
}
},
});Router would render a
loadingComponent ↗
when redirect is called during a data loading or during rendering.
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') {
// We don't care about non-redirect events in this example
return;
}
if (typeof event.to === 'string') {
window.location.href = event.to;
return;
}
// Navigate a router when redirected to a location
router.navigate(event.to);
});If you want the browser history to drive your redirects, see the History section.
Prefetching
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} />Or usePrefetch ↗
hook:
usePrefetch(productRoute);React Corsair triggers required data loaders on every navigation, so you may need to implement caching for data loaders.
By default, both Prefetch and usePrefetch start prefetching right after mount. Provide a prefetch trigger that would
start prefetching when a condition is met:
import { useRef, useMemo } from 'react';
import { createHoveredPrefetchTrigger, usePrefetch } from 'react-corsair';
const ref = useRef<Element | null>(null);
const prefetchTrigger = useMemo(() => createHoveredPrefetchTrigger(ref), [ref]);
usePrefetch(productRoute, prefetchTrigger);
<button ref={ref}>{'Go to product'}</button>;When button is hovered, usePrefetch would start prefetching productRoute.
React Corsair provides two prefetch triggers:
createHoveredPrefetchTrigger↗ Creates a trigger that start prefetching when an element is hovered.createVisiblePrefetchTrigger↗ Creates a trigger that start prefetching when an element is at least 50% visible on the screen.
Create a custom prefetch trigger:
import { usePrefetch, PrefetchTrigger } from 'react-corsair';
// Starts prefetching after mount with a 5 second delay
const prefetchTrigger: PrefetchTrigger = useMemo(
() => prefetch => {
const timer = setTimeout(prefetch, 5000);
return () => clearTimeout(timer);
},
[]
);
usePrefetch(productRoute, prefetchTrigger);Route interception
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({ sku: 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 productRouteController = useInterceptedRoute(productRoute);
// ⮕ RouteController | null
return (
<>
<Link to={productRoute.getLocation({ sku: 42 })}>{'Go to product'}</Link>
{productRouteController !== null && <RouteOutlet controller={productRouteController} />}
</>
);
}Now when user clicks on Go to product, the browser location changes to /product/42 and <ShopPage> is re-rendered.
productRouteController 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
Inline routes allow rendering a route that matches a location inside a component:
import { useInlineRoute, RouteOutlet } from 'react-corsair';
function Product() {
const productRouteController = useInlineRoute(productRoute.getLocation({ sku: 42 }));
return productRouteController !== null && <RouteOutlet controller={productRouteController} />;
}useInlineRoute ↗
matches the provided location against routes of the current router and returns a corresponding route controller.
History
React Corsair provides a seamless history integration:
import { Router, RouterProvider, userRoute } from 'react-corsair';
import { createBrowserHistory, HistoryProvider } from 'react-corsair/history';
const history = createBrowserHistory();
history.start();
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 (
// 3️⃣ 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 that uses HTML5 history API.createHashBrowserHistory↗ is a DOM-specific history adapter that uses HTML5 history API and stores location in a URL hash ↗. This is useful if your server doesn't support history fallback, or if you're shipping an HTML file.createMemoryHistory↗ is an in-memory history adapter, useful in testing and non-DOM environments like SSR.
Route URLs
History converts locations to URLs and vice-versa:
const helloRoute = createRoute<{ name?: string }>('/hello');
const history = createBrowserHistory();
const helloURL = history.toURL(helloRoute.getLocation({ name: 'Bob' }));
// ⮕ '/hello?name=Bob'
history.parseURL(helloURL);
// ⮕ { pathname: '/hello', searchParams: { name: 'Bob' }, hash: '', state: undefined }Use basePathname ↗
to set the base pathname for the entire history. URLs produced by history would share the base pathname:
createBrowserHistory({ basePathname: '/suuuper' }).toURL(helloRoute);
// ⮕ '/suuuper/hello'
createHashBrowserHistory({ basePathname: '/suuuper' }).toURL(helloRoute);
// ⮕ '/suuuper#/hello'An error is thrown if a parsed URL doesn't match the base pathname:
createBrowserHistory({ basePathname: '/suuuper' }).parseURL('/ooops');
// ❌ Error: Pathname doesn't match the required base: /suuuperURLs can be passed to
push ↗ and
replace ↗
methods:
const history = createBrowserHistory({ basePathname: '/suuuper' });
history.start();
history.push(helloRoute);
// same as
history.push(history.toURL(helloRoute));
// same as
history.push('/suuuper/hello');Absolute and relative URLs
There's no relative navigation in React Corsair. This is reflected in the history behavior: history treats all URLs as absolute:
const history = createBrowserHistory();
history.start();
history.push('/hello');
// same as
history.push('hello');Even pushing a hash isn't relative:
history.push('#ooops');
// same as
history.push('/#ooops');This approach makes navigation predictable: what URL you see in the code, is exactly the same URL you would see in the browser location bar.
Both location and URL navigation work the same way: always absolute. But you should prefer locations whenever possible:
// 🟢 Great
history.push(helloRoute);
// 🟢 OK
history.push(history.toURL(helloRoute));
// 🟡 Better avoid
history.push('/hello');Search strings
When history serializes a URL, it uses an serializer 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=["electronics","gifts"]&sortBy=price&available=true'jsonSearchParamsSerializer allows you to store complex data structures in a URL.
You can create
a custom search params serializer ↗
and provide it to a history. Here's how to create a basic serializer that
uses URLSearchParams ↗:
createBrowserHistory({
searchParamsSerializer: {
parse: search => Object.fromEntries(new URLSearchParams(search)),
stringify: params => new URLSearchParams(params).toString(),
},
});Links
Inside components
use <Link> ↗
for navigation:
import { Link } from 'react-corsair/history';
function ProductPage() {
return <Link to={productRoute.getLocation({ sku: 42 })}>{'Go to a product 42'}</Link>;
}Links can automatically prefetch a route component and related data as soon as they are rendered:
<Link
to={productRoute.getLocation({ sku: 42 })}
isPrefetched={true}
>
{'Go to a product 42'}
</Link>Navigation blocking
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.
Use
the useHistoryBlocker ↗
hook to intercept the navigation attempt and show a browser confirmation popup to the user:
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useHistoryBlocker(() => {
// 🟡 Return true to cancel navigation or false to proceed
return hasUnsavedChanges && !confirm('Discard unsaved changes?');
});A blocker function provided to the useHistoryBlocker hook receives a navigation transaction.
With proceed ↗
and
cancel ↗
methods 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 !== null && (
<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();Server-side rendering
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 tohydrateRouterand set of routes passed to theRouteron the client-side must be the same as ones used during the server-side rendering. Otherwise, hydration behavior is undefined.
hydrateRouter ↗
must be called only once on the client-side with the router that would receive the dehydrated state from the server.
On the server-side, you can either render your app contents as a string and send it to the client in one go, or stream the contents.
Rendering disposition
By default, 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:
Render to string
Use SSRRouter ↗ to render
your app as an HTML string:
import { createServer } from 'node: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({ initialEntries: [request.url] });
const router = new SSRRouter({ routes: [helloRoute] });
// 2️⃣ Navigate router to a requested location
router.navigate(history.location);
// 3️⃣ Re-render until there are no more changes
let html;
do {
html = renderToString(
<HistoryProvider value={history}>
<RouterProvider value={router} />
</HistoryProvider>
);
} while (await router.hasChanges());
// 3️⃣ Inject the hydration script
html = html.replace('</body>', router.nextHydrationChunk() + '</body>');
response.setHeader('Content-Type', 'text/html');
response.end(html);
});
server.listen(8080);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.
Streaming SSR
React can stream parts of your app while it is being rendered. You can inject React Corsair hydration chunks into the React stream.
import { createServer } from 'node:http';
import { Writable } from 'node:stream';
import { renderToReadableStream } 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({ initialEntries: [request.url] });
const router = new SSRRouter(response, { routes: [helloRoute] });
// 2️⃣ Navigate router to a requested location
router.navigate(history.location);
const stream = await renderToReadableStream(
<HistoryProvider value={history}>
<RouterProvider value={router} />
</HistoryProvider>
);
const hydrator = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk);
controller.enqueue(router.nextHydrationChunk());
},
});
response.setHeader('Content-Type', 'text/html');
// 2️⃣ Inject the hydration chunks into the react stream
await stream.pipeThrough(hydrator).pipeTo(Writable.toWeb(response));
response.end();
});
server.listen(8080);hydrator injects React Executor hydration chunks into the React stream.
State serialization
By default, route state is serialized using
JSON ↗
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 server, pass
a serializer ↗
option to SSRRouter:
import { SSRRouter } from 'react-corsair/ssr';
import JSONMarshal from 'json-marshal';
const router = new SSRRouter({
routes: [helloRoute],
serializer: JSONMarshal,
});On the client, pass the same
serializer ↗
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 serializer
serializer: JSONMarshal,
});
hydrateRoot(
document,
<HistoryProvider value={history}>
<RouterProvider value={router} />
</HistoryProvider>
);[!TIP]
Read more about JSON Marshal ↗, it can stringify and parse any data structure.
Content-Security-Policy support
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'Cookbook
Route masking
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 a user to be logged in
const privateRoutes = new Set([adminRoute]);
router.subscribe(event => {
if (event.type === 'navigate' && !isUserLoggedIn() && privateRoutes.has(event.controller.route)) {
router.navigate(loginPage);
}
});Forbidden error
To render an error-specific UI for a HTTP 403 Forbidden status, create an special error class and a helper function:
class ForbiddenError extends Error {}
function forbidden(): never {
throw new ForbiddenError();
}Create a route that throws a ForbiddenError:
function AdminPanel() {
isAdmin() || forbidden();
// Render admin panel here
}
const adminRoute = createRoute('/admin', AdminPanel);Handle ForbiddenError in an errorComponent of either a route or a router:
const router = new Router({
routes: [adminRoute],
errorComponent: () => {
const { error } = useRoute();
if (error instanceof ForbiddenError) {
return 'Forbidden';
}
return 'An error occurred';
},
});You can use forbidden() in a data loader or during rendering of a route component.
