npm install --save-prod react-corsair
- Overview
- Router and routes
- Conditional routing
- Nested routes
- Pathname templates
- Route params
- Route locations
- Navigation
- Code splitting
- Error boundaries
- Not found
- History integration
React Corsair is a router that abstracts URLs away from the domain of your application. It doesn't depend on
History
but can be easily
integrated with it.
Create a component that renders a page of your app:
function UserPage() {
return 'Hello!';
}
Create a Route
that maps a pathname to
a page component:
import { createRoute } from 'react-corsair';
const userRoute = createRoute('/user', UserPage);
Render Router
component to set up a router:
import { useState } from 'react';
import { Router } from 'react-corsair';
function App() {
const [location, setLocation] = useState<Location>({
pathname: '/user',
searchParams: {},
hash: ''
});
return (
<Router
location={location}
routes={[userRoute]}
onPush={setLocation}
/>
);
}
Inside page components use Navigation
to
trigger location changes:
import { useNavigation } from 'react-corsair';
function TeamPage() {
const navigation = useNavigation();
return (
<button onClick={() => navigation.push(userRoute)}>
{'Go to user'}
</button>
);
}
To create a route that matches a pathname to a component use the
createRoute
function:
import { createRoute } from 'react-corsair';
function UserPage() {
return 'Hello';
}
const userRoute = createRoute('/user', UserPage);
This is the same as providing options
to createRoute
:
const userRoute = createRoute({
pathname: '/user',
component: UserPage
});
To render a route, a Router
must be rendered with that route:
import { Router } from 'react-corsair';
function App() {
const [location, setLocation] = useState<Location>({
pathname: '/user',
searchParams: {},
hash: ''
});
return (
<Router
location={location}
routes={[userRoute]}
/>
);
}
Router would take a pathname from the
location
and match it against
the given set of routes
in
the same order they were listed in an array. The route which pathname matched the whole location.pathname
is then
rendered.
Where does location come from? From a component state, from React context, from browser history,
from anywhere really. The only job the Router
does is match the route with location and render.
If there's no route that matched the provided location, then
a notFoundComponent
is
rendered:
function NotFound() {
return 'No routed matched the location';
}
function App() {
return (
<Router
location={location}
routes={[userRoute]}
notFoundComponent={NotFound}
/>
);
}
By default, notFoundComponent
is undefined
, so nothing is rendered if no route matched.
Router
can receive children that it would render:
import { Router, Outlet } from 'react-corsair';
function App() {
return (
<Router
location={location}
routes={[userRoute]}
>
<nav>
{/* Some navigation here */}
</nav>
<main>
{/* 🟡 Outlet renders a matched route */}
<Outlet/>
</main>
</Router>
);
}
If you provide children to a Router
, be sure to render
an <Outlet />
somewhere in that markup. An outlet
is a mounting point for a matched route.
In this example, if userRoute
is matched, the rendered output would be:
<nav></nav>
<main>Hello</main>
You can compose routes array on the fly to change what routes a user can reach depending on external factors.
const postsRoute = createRoute('/posts', PostsPage);
const settingsRoute = createRoute('/settings', SettingsPage);
postsRoute
should be available to all users, while settingsRoute
should be available only to logged-in users. If
user isn't logged in and location provided to Router
has "/settings"
pathname, then a notFoundComponent
must be
rendered:
function App() {
const routes = [postsRoute];
// 🟡 Add a route on the fly
if (isLoggedIn) {
routes.push(settingsRoute);
}
return (
<Router
location={location}
routes={routes}
/>
);
}
Now, be sure that App
is re-rendered every time isLoggedIn
is changed, so Router
would catch up the latest set of
routes.
Router uses <Outlet />
to render a matched route. Route components can render outlets as well:
import { Outlet } from 'react-corsair';
function SettingsPage() {
return <Outlet />
}
Now we can leverage that nested outlet and create a nested route:
const settingsRoute = createRoute('/settings', SettingsPage);
// 🟡 BillingPage is rendered in an Outlet inside SettingsPage
const billingRoute = createRoute(settingsRoute, '/billing', BillingPage);
const notificationsRoute = createRoute(settingsRoute, '/notifications', NotificationsPage);
Provide these routes to the Router
:
function App() {
return (
<Router
loaction={location}
routes={[
billingRoute,
notificationsRoute
]}
/>
);
}
Now if location.pathname
is "/settings/notifications"
, a NotificationsPage
would be rendered in an <Outlet />
of SettingsPage
.
While SettingsPage
can contain any markup around an outlet to decorate the page, in the current example there's
nothing special about the SettingsPage
. If you omit the component when creating a route, a route would render an
<Outlet />
by default. So settingsRoute
can be simplified:
- const settingsRoute = createRoute('/settings', SettingsPage);
+ const settingsRoute = createRoute('/settings');
Since settingsRoute
wasn't provided to the Router
, it will never be matched. So if user navigates to "/settings"
,
a notFoundComponent
would be rendered with the current setup.
This can be solved in one of the following ways.
- By adding an index route to
Router.routes
:
const settingsIndexRoute = createRoute(settingsRoute, '/', BillingPage);
function App() {
return (
<Router
loaction={location}
routes={[
settingsIndexRoute,
notificationsRoute,
billingRoute
]}
/>
);
}
This option isn't great because now you have a separate route that you can navigate to.
- By making an optional segment in one of existing routes:
- const billingRoute = createRoute(settingsRoute, '/billing', BillingPage);
+ const billingRoute = createRoute(settingsRoute, '/billing?', BillingPage);
With this setup, user can navigate to "/settings"
and "/settings/billing"
and would see the same content on
different URLs which isn't great either.
- By rendering a redirect:
import { redirect } from 'react-corsair';
const settingsRoute = createRoute('/settings', () => redirect(billingRoute));
Here, settingsRoute
renders a redirect to billingRoute
every time it is matched by the Router
.
A pathname provided for a route is parsed as a pattern. Pathname patterns may contain named params and other metadata.
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 adminRoute = createRoute('/admin');
adminRoute.pathnameTemplate.pattern;
// ⮕ '/admin'
By default, a pathname pattern is case-insensitive. So the route in example above would match both "/admin"
and
"/ADMIN"
.
If you need a case-sensitive pattern, provide
isCaseSensitive
route
option:
createRoute({
pathname: '/admin',
isCaseSensitive: true
});
Pathname patterns can include params that conform :[A-Za-z$_][A-Za-z0-9$_]+
:
const userRoute = createRoute('/user/:userId');
You can retrieve param names at runtime:
userRoute.pathnameTemplate.paramNames;
// ⮕ Set { 'userId' }
Params match a whole segment and cannot be partial.
createRoute('/teams--:teamId');
// ❌ SyntaxError
createRoute('/teams/:teamId');
// ✅ 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('/user/:userId?');
This route matches both "/user"
and "/user/37"
.
Static pathname segments can be optional as well:
createRoute('/project/task?/:taskId');
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');
You can access matched pathname and search params in route components:
import { createRoute, useRouteState } from 'react-corsair';
interface TeamParams {
teamId: string;
sortBy: 'username' | 'createdAt';
}
const teamRoute = createRoute<UserParams>('/teams/:teamId', TeamPage);
function TeamPage() {
const { params } = useRouteState(teamRoute);
// 🟡 The params type was inferred from the teamRoute.
return `Team ${params.teamId} is sorted by ${params.sortBy}.`;
}
Here we created the teamRoute
route that has a teamId
pathname param and a required sortBy
search param. We added
an explicit type to createRoute
to enhance type inference during development. While this provides great DX, there's
no guarantee that params would match the required schema at runtime. For example, user may provide an arbitrary string
as sortBy
search param value, or even omit this param.
A route can parse and validate params at runtime with
a paramsAdapter
:
const teamRoute = createRoute({
pathname: '/team/:teamId',
paramsAdapter: params => {
// Parse or validate params here
return {
teamId: params.teamId,
sortBy: params.sortBy === 'username' || params.sortBy === 'createdAt' ? params.sortBy : 'username'
};
}
});
Now sortBy
is guaranteed to be eiter "username"
or "createdAt"
inside your route components.
To enhance validation even further, you can use a validation library like Doubter or Zod:
import * as d from 'doubter';
const teamRoute = createRoute({
pathname: '/team/:teamId',
paramsAdapter: d.object({
teamId: d.string(),
sortBy: d.enum(['username', 'createdAt']).catch('username')
})
});
Every route has a pathname template that can be used to create a route location.
const adminRoute = createRoute('/admin');
adminRoute.getLocation();
// ⮕ { pathname: '/admin', searchParams: {}, hash: '' }
If route is parameterized, then params must be provided to
the getLocation
method:
const userRoute = createRoute('/user/:userId');
userRoute.getLocation({ userId: 37 });
// ⮕ { pathname: '/user/37', searchParams: {}, hash: '' }
userRoute.getLocation();
// ❌ Error: Param must be a string: userId
By default, route treats all params that aren't used by pathname template as search params:
const teamRoute = createRoute('/team/:teamId');
teamRoute.getLocation({
teamId: 42,
sortBy: 'username'
});
// ⮕ { pathname: '/team/42', searchParams: { sortBy: 'username' }, hash: '' }
Let's add some types, to constrain route param type inference and enhance DX:
interface UserParams {
userId: string;
}
const userRoute = createRoute<UserParams>('/user/:userId');
userRoute.getLocation({});
// ❌ TS2345: Argument of type {} is not assignable to parameter of type { userId: string; }
TypeScript raises an error if userRoute
receives insufficient number params.
Tip
It is recommended to use
paramsAdapter
to constrain route params at runtime. Read more about param adapters in the Route params section.
Router
repeats route matching only if location
or routes
change. Router
supports onPush
, onReplace
, and
onBack
callbacks that are triggered when a navigation is requested. To request a navigation use useNavigation
hook:
import { useNavigation } from 'react-corsair';
function TeamPage() {
const navigation = useNavigation();
return (
<button onClick={() => navigation.push(userRoute)}>
{'Go to user'}
</button>
);
}
Here, navigation.push
triggers
RouterProps.onPush
with
the location of userRoute
.
If user userRoute
has params, then you must first create a location with that params:
navigation.push(userRoute.getLocation({ userId: 42 }));
To enable code splitting in your app, use
the lazyComponent
option,
instead of the component
:
const userRoute = createRoute({
pathname: '/user',
lazyComponent: () => import('./UserPage')
});
When userRoute
is matched by router, a chunk that contains UserPage
is loaded and rendered. The loaded component is
cached, so next time the userRoute
is matched, UserPage
would be rendered instantly.
By default, while a lazy component is being loaded, Router
would still render the previously matched route.
But what is rendered if the first ever route matched the Router
has a lazy component and there's no content yet on
the screen? By default, in this case nothing is rendered until a lazy component is loaded. This is no a good UX, so you
may want to provide
a loadingComponent
option to your route:
function LoadingIndicator() {
return 'Loading';
}
const userRoute = createRoute({
pathname: '/user',
lazyComponent: () => import('./UserPage'),
loadingComponent: LoadingIndicator
});
Now, loadingComponent
would be rendered by Router
if there's nothing rendered yet.
Each route may have a custom loading component: here you can render a page skeleton or a spinner.
Router would still render the previously matched route even if a newly matched route has a loadingComponent
. You can
change this by adding
a loadingAppearance
option:
const userRoute = createRoute({
pathname: '/user',
lazyComponent: () => import('./UserPage'),
loadingComponent: LoadingIndicator,
loadingAppearance: 'loading'
});
This tells Router
to always render userRoute.loadingComponent
when userRoute
is matched and lazy component isn't
loaded yet. loadingAppearance
can be set to:
"loading"
-
A
loadingComponent
is always rendered if a route is matched and a component or a data loader are being loaded. "auto"
-
If another route is currently rendered then it would be preserved until a component and data loader of a newly matched route are being loaded. Otherwise, a
loadingComponent
is rendered. This is the default value.
If an error is thrown during lazyComponent
loading, an error boundary is rendered and Router
would retry loading the component again later.
Each route has a built-in
error boundary. When an
error occurs during component rendering,
an errorComponent
is
rendered:
function UserPage() {
throw new Error('Ooops!');
}
function ErrorFallback() {
return 'An error occurred';
}
const userRoute = createRoute({
pathname: '/user',
component: UserPage,
errorComponent: ErrorFallback
});
You can access the error that triggered the error boundary within an error component:
import { userRouteState } from 'react-corsair';
function ErrorFallback() {
const { error } = userRouteState(userRoute);
return 'The error occurred: ' + error;
}
If during route component rendering, you detect that there's not enough data to render a route, call
the notFound
function:
import { useRouteState } from 'react-corsair';
function UserPage() {
const { params } = useRouteState()
const user = useUser(params.userId);
if (!user) {
notFound();
}
return 'Hello, ' + user.firstName;
}
notFound
throws a NotFoundError
that
triggers an error boundary and causes Router
to render
a notFoundComponent
:
function UserNotFound() {
return 'User not found';
}
const userRoute = createRoute({
pathname: '/user/:userId',
component: UserPage,
notFoundComponent: UserNotFound
});
React Corsair provides history integration:
import { Router, createBrowserHistory, useHistorySubscription } from 'react-corsair';
import { userRoute } from './routes';
const history = createBrowserHistory();
function App() {
useHistorySubscription(history);
return (
<Router
location={history.location}
routes={[userRoute]}
onPush={history.push}
onBack={history.back}
/>
);
}