react-corsair
v0.0.4
Published
Because routes are better then URLs.
Downloads
15
Readme
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
- Triggering not found
- History integration
- Data loading
- Prefetching
Overview
URLs don't matter because they aren't a part of your domain. React Corsair is a router that abstracts URLs away from
your application. It replaces URLs
with Route
objects that can be used for
location matching, params validation, navigation, type inference, etc.
React Corsair can be used in any environment and doesn't require any browser-specific API to be available. Browser history integration is optional.
To showcase how the router works in the browser environment, lets start by creating a page component:
function UserPage() {
return 'Hello';
}
Then create a Route
that maps a URL
pathname to a page component:
import { createRoute } from 'react-corsair';
const userRoute = createRoute('/user', UserPage);
Render the <Router>
component to set up a router:
import { useState } from 'react';
import { Router, createBrowserHistory, useHistorySubscription } from 'react-corsair';
const history = createBrowserHistory();
function App() {
useHistorySubscription(history);
return (
<Router
location={history.location}
routes={[userRoute]}
onPush={history.push}
onBack={history.back}
/>
);
}
Inside route components use the <Link>
component
for navigation:
import { Link } from 'react-corsair';
function TeamPage() {
return (
<Link to={userRoute}>
{'Go to user'}
</Link>
);
}
Or use the useNavigation
hook to trigger
navigation imperatively:
import { useNavigation } from 'react-corsair';
function TeamPage() {
const navigation = useNavigation();
return (
<button onClick={() => navigation.push(userRoute)}>
{'Go to user'}
</button>
);
}
Router and routes
To create a route that maps a pathname to a component use the
createRoute
function:
const userRoute = createRoute('/user', UserPage);
You can provide options to createRoute
:
const userRoute = createRoute({
pathname: '/user',
component: UserPage
});
<Router>
component is the heart of React Corsair.
It takes a pathname from the provided
location
and matches it against
the given set of routes
in
the same order they were listed. Then router renders a component from a route which pathname matched the
location.pathname
in full.
import { Router } from 'react-corsair';
function App() {
return (
<Router
location={{ pathname: '/user' }}
routes={[userRoute]}
/>
);
}
Where does a location come from? From a component state, from a React context, from the browser history, from anywhere really:
function App() {
const [location, setLocation] = useState({ pathname: '/user' });
return (
<Router
location={location}
routes={[userRoute]}
onPush={setLocation}
onReplace={setLocation}
/>
);
}
Outlets
Router
uses <Outlet>
to render routes.
If you don't provide any children to the Router
then it renders an Outlet
by default. You can provide custom
children to Router
, for example, to wrap route components in an additional markup:
import { Router, Outlet } from 'react-corsair';
const userRoute = createRoute('/user', UserPage);
function UserPage() {
return 'Hello';
}
function App() {
return (
<Router
location={{ pathname: '/user' }}
routes={[userRoute]}
>
<main>
{/* 🟡 Outlet renders the matched route */}
<Outlet/>
</main>
</Router>
);
}
The rendered output would be:
<main>Hello</main>
Not found
If there's no route that matched the provided location, then
a notFoundComponent
is
rendered:
function NotFound() {
return 'Not found';
}
function App() {
return (
<Router
location={{ pathname: '/ooops' }}
routes={[userRoute]}
notFoundComponent={NotFound}
/>
)
}
By default, notFoundComponent
is undefined
, so nothing is rendered if no route matched.
Conditional routing
You can compose the routes
array
on-the-fly to change what routes a user can reach depending on external factors.
Consider an app with two rotes "/posts"
and "/settings"
. "/posts"
should be available to all users,
while "/settings"
should be available only to logged-in users. If user isn't logged and navigates to "/settings"
,
then a notFoundComponent
must be rendered.
const postsRoute = createRoute('/posts', PostsPage);
const settingsRoute = createRoute('/settings', SettingsPage);
function App() {
const [location] = useState({ pathname: '/settings' });
const routes = [postsRoute];
// 🟡 Add a route on-the-fly
if (isLoggedIn) {
routes.push(settingsRoute);
}
return (
<Router
location={location}
routes={routes}
/>
);
}
Be sure that App
is re-rendered every time isLoggedIn
is changed, so <Router>
would catch up the latest set of
routes.
Nested routes
<Router>
uses an 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);
const billingRoute = createRoute(settingsRoute, '/billing', BillingPage);
BillingPage
would be rendered in an Outlet
inside SettingsPage
.
Provide billingRoute
to the <Router>
:
function App() {
return (
<Router
loaction={{ pathname: '/settings/billing' }}
routes={[billingRoute]}
/>
);
}
While SettingsPage
can render any markup around an <Outlet>
to decorate the page, in the current example there's
no additional markup. If you omit the component when creating a route, a route would render an <Outlet>
by default:
- const settingsRoute = createRoute('/settings', SettingsPage);
+ const settingsRoute = createRoute('/settings');
Rendering nested routes
Since settingsRoute
wasn't provided to the <Router>
, it will never be matched. So if user navigates
to "/settings"
,
a notFoundComponent
is rendered.
There are several approaches on how to avoid Not Found in such case:
- Add an index route to
routes
.
const settingsIndexRoute = createRoute(settingsRoute, '/', BillingPage);
function App() {
return (
<Router
loaction={{ pathname: '/settings' }}
routes={[
settingsIndexRoute,
billingRoute
]}
/>
);
}
- Make 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 usually isn't a great idea.
- Render 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>
.
Pathname templates
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');
Route params
You can access matched pathname params 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')
})
});
Route locations
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 of params.
[!TIP]
It is recommended to useparamsAdapter
to constrain route params at runtime. Read more about param adapters in the Route params section.
Navigation
<Router>
does route matching only if
a location
or
routes
have changed.
Provide onPush
,
onReplace
, and
onBack
callbacks to <Router>
to be notified when a location change is requested.
To request a navigation from route components use
the 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
onPush
with
the location of userRoute
.
If user userRoute
has params, then provide an explicit route location:
navigation.push(userRoute.getLocation({ userId: 42 }));
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')
});
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 by 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 the <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 when a new route is being loaded, even if a new 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:
A loadingComponent
is always rendered if a route is matched and a component or a data loader are being loaded.
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.
Error boundaries
Each route is rendered in its own
error boundary. When an
error occurs during route component rendering,
an errorComponent
is
rendered as a fallback:
function UserPage() {
throw new Error('Ooops!');
}
function ErrorFallback() {
return '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 'Error occurred: ' + error;
}
Triggering not found
During route component rendering, you may detect that there's not enough data to render a route. Call
the notFound
function in such case:
import { notFound, useRouteState } from 'react-corsair';
function UserPage() {
const { params } = useRouteState(userRoute);
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
as a fallback:
function UserNotFound() {
return 'User not found';
}
const userRoute = createRoute({
pathname: '/user/:userId',
component: UserPage,
notFoundComponent: UserNotFound
});
History integration
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}
/>
);
}
There are three types of history adapters that you can leverage:
Inside route components use the <Link>
component
for navigation:
import { Link } from 'react-corsair';
function TeamPage() {
return (
<Link to={userRoute}>
{'Go to user'}
</Link>
);
}
If a route that link should navigate to is parameterized, provide a route location:
<Link to={userRoute.getLocation({ userId: 42 })}>
{'Go to user'}
</Link>
Links can automatically prefetch a route component and related data:
<Link
to={userRoute}
prefetch={true}
>
{'Go to user'}
</Link>
Data loading
Routes may require some data to render. While you can load that data from inside a route component, this may lead to a waterfall. React Corsair provides an easy way to load your data along with the route:
const userRoute = createRoute({
pathname: '/users/:userId',
lazyComponent: () => import('./UserPage'),
loader: async params => {
const res = await fetch('/api/users/' + params.userId);
return res.json();
}
});
loader
is called every time the
<Router>
matches the route. Router waits for both component and data to be loaded and then renders the component.
You can access the loaded data in your route component using the useRouteState hook:
function UserPage() {
const { data } = useRouteState(userRoute);
// Render the data here
}
Your data loader can access a context of the <Router>
:
interface MyRouterContext {
apiBase: string;
}
const userRoute = createRoute({
pathname: '/users/:userId',
lazyComponent: () => import('./UserPage'),
loader: async (params, context: MyRouterContext) => {
const res = await fetch(context.apiBase + '/users/' + params.userId);
return res.json();
}
});
function App() {
return (
<Router
location={{ pathname: '/user/42' }}
routes={[userRoute]}
context={{
// 🟡 Context type is inferred from the userRoute
apiBase: 'https://superpuper.com'
}}
/>
)
}
Prefetching
Sometimes you know ahead of time that 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, you can eiter call prefetch
method on a route itself:
userRoute.prefetch({ userId: 42 });
Or user Navigation
to prefetch a location:
const navigation = useNavigation();
navigation.prefetch(userRoute.getLocation({ userId: 42 }));
Navigation
would prefetch routes only if they were provided to <Router>
.