Skip to content

smikhalevski/react-corsair

Repository files navigation

React Corsair

npm install --save-prod react-corsair

  🔥 Live example

Overview

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

Router and routes

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>

Conditional routing

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.

Nested 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');

Matching nested routes

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.

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

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

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

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

Tip

It is recommended to use paramsAdapter to constrain route params at runtime. Read more about param adapters in the Route params section.

Navigation

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

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

Error boundaries

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

Not found

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

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