diff --git a/README.md b/README.md index 4cd252b..a1be281 100644 --- a/README.md +++ b/README.md @@ -12,35 +12,97 @@ npm install --save-prod react-corsair 🔥 [**Live example**](https://codesandbox.io/p/sandbox/react-corsair-example-mzjzcm) -# Introduction +# Overview React Corsair is a router that abstracts URLs away from the domain of your application. It doesn't depend on -[`History`](https://developer.mozilla.org/en-US/docs/Web/API/History) but can be easily integrated with it. +[`History`](https://developer.mozilla.org/en-US/docs/Web/API/History) but can be easily +[integrated with it](#history-integration). -Create a component that renders the page of your app: +Create a component that renders a page of your app: ```ts -export default function UserPage() { - return 'Hello'; +function UserPage() { + return 'Hello!'; } ``` -Create a [`Route`](https://smikhalevski.github.io/react-corsair/classes/Route.html) that lazy-loads the page component: +Create a [`Route`](https://smikhalevski.github.io/react-corsair/functions/createRoute.html) that maps a pathname to +a page component: ```ts import { createRoute } from 'react-corsair'; +const userRoute = createRoute('/user', UserPage); +``` + +Render [`Router`](https://smikhalevski.github.io/react-corsair/classes/Router.html) component to set up a router: + +```tsx +import { useState } from 'react'; +import { Router } from 'react-corsair'; + +function App() { + const [location, setLocation] = useState({ + pathname: '/user', + searchParams: {}, + hash: '' + }); + + return ( + + ); +} +``` + +Inside page components use [`Navigation`](https://smikhalevski.github.io/react-corsair/classes/Navigation.html) to +trigger location changes: + +```tsx +import { useNavigation } from 'react-corsair'; + +function TeamPage() { + const navigation = useNavigation(); + + return ( + + ); +} +``` + +# Router and routes + +To create a route that matches a pathname to a component use the +[`createRoute`](https://smikhalevski.github.io/react-corsair/functions/createRoute.html) function: + +```ts +import { createRoute } from 'react-corsair'; + +function UserPage() { + return 'Hello'; +} + +const userRoute = createRoute('/user', UserPage); +``` + +This is the same as providing [options](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html) +to `createRoute`: + +```ts const userRoute = createRoute({ pathname: '/user', - lazyComponent: () => import('./UserPage') + component: UserPage }); ``` -Render [`Router`](https://smikhalevski.github.io/react-corsair/classes/Router.html) component to set up -the router: +To render a route, a `Router` must be rendered with that route: ```tsx -import { useState } from 'react'; import { Router } from 'react-corsair'; function App() { @@ -54,27 +116,620 @@ function App() { ); } ``` -Access [`Navigation`](https://smikhalevski.github.io/react-corsair/classes/Navigation.html) in components rendered by -the `Router`: +Router would take a pathname from the provided +[`location`](https://smikhalevski.github.io/react-corsair/interfaces/RouterProps.html#location) and match it against +the given set of [`routes`](https://smikhalevski.github.io/react-corsair/interfaces/RouterProps.html#routes) in +the same order they were provided. 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](#history-integration), +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`](https://smikhalevski.github.io/react-corsair/interfaces/RouterProps.html#notFoundComponent) is +rendered: + +```tsx +function NotFound() { + return 'Not found'; +} + +function App() { + return ( + + ); +} +``` + +By default, `notFoundComponent` is `undefined`, so nothing is rendered if no route matched. + +`Router` can receive children that it would render: + +```tsx +import { Router, Outlet } from 'react-corsair'; + +function App() { + return ( + + +
+ {/* 🟡 Outlet renders a matched route */} + +
+
+ ); +} +``` + +If you provide children to a `Router`, be sure to render +an [``](https://smikhalevski.github.io/react-corsair/classes/Outlet.html) 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: + +```html + +
Hello
+``` + +# Conditional routing + +You can compose routes array on the fly to change what routes a user can reach depending on external factors. + +```ts +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: + +```tsx +function App() { + const routes = [postsRoute]; + + // 🟡 Add a route on the fly + if (isLoggedIn) { + routes.push(settingsRoute); + } + + return ( + + ); +} +``` + +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 `` to render a matched route. Route components can render outlets as well: + +```tsx +import { Outlet } from 'react-corsair'; + +function SettingsPage() { + return +} +``` + +Now we can leverage that nested outlet and create a nested route: + +```ts +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`: + +```tsx + +``` + +Now if `location.pathname` is `"/settings/notifications"`, a `NotificationsPage` would be rendered in an `` +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 +`` by default. So `settingsRoute` can be simplified: + +```diff +- 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`](https://smikhalevski.github.io/react-corsair/interfaces/RouterProps.html#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`: + +```tsx +const settingsIndexRoute = createRoute(settingsRoute, '/', BillingPage); + + +``` + +This option isn't great because now you have a separate route that you can navigate to. + +2. By making an optional segment in one of existing routes: + +```diff +- 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. + +3. By rendering a redirect: + +```ts +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`](https://smikhalevski.github.io/react-corsair/classes/PathnameTemplate.html) 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: + +```ts +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`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#isCaseSensitive) route +option: + +```ts +createRoute({ + pathname: '/admin', + isCaseSensitive: true +}); +``` + +Pathname patterns can include params that conform `:[A-Za-z$_][A-Za-z0-9$_]+`: + +```ts +const userRoute = createRoute('/user/:userId'); +``` + +You can retrieve param names at runtime: + +```ts +userRoute.pathnameTemplate.paramNames; +// ⮕ Set { 'userId' } +``` + +Params match a whole segment and cannot be partial. + +```ts +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. + +```ts +createRoute('/user/:userId?'); +``` + +This route matches both `"/user"` and `"/user/37"`. + +Static pathname segments can be optional as well: + +```ts +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. + +```ts +createRoute('/:slug*'); +``` + +This route matches both `"/watch"` and `"/watch/a/movie"`. + +To make param both wildcard and optional, combine `*` and `?` flags: + +```ts +createRoute('/:slug*?'); +``` + +To use `:` as a character in a pathname pattern, replace it with +an [encoded](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) +representation `%3A`: + +```ts +createRoute('/foo%3Abar'); +``` + +# Route params + +You can access matched [pathname](#pathname-templates) and search params in route components: + +```ts +import { createRoute, useRouteState } from 'react-corsair'; + +interface TeamParams { + teamId: string; + sortBy: 'username' | 'createdAt'; +} + +const teamRoute = createRoute('/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 for a `sortBy` search param, or even omit this param. + +A route can parse and validate params in runtime with +a [`paramsAdapter`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#paramsAdapter): + +```ts +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](https://github.com/smikhalevski/doubter?tab=readme-ov-file) or +[Zod](https://github.com/colinhacks/zod?tab=readme-ov-file): + +```ts +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](#pathname-templates) that can be used to create a route location. + +```ts +const adminRoute = createRoute('/admin'); + +adminRoute.getLocation(); +// ⮕ { pathname: '/admin', searchParams: {}, hash: '' } +``` + +If route has a pathname param, it must be provided to +the [`getLocation`](https://smikhalevski.github.io/react-corsair/classes/Route.html#getLocation) method: + +```ts +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 considers all params that aren't used by pathname template as search params: + +```ts +const teamRoute = createRoute('/team/:teamId'); + +teamRoute.getLocation({ + teamId: 42, + sortBy: 'username' +}); +// ⮕ { pathname: '/team/42', searchParams: { sortBy: 'username' }, hash: '' } +``` + +In previous examples, we didn't constrain types of route params. This is inconvenient during development since +`getLocation` would impose only runtime checks. Let's add some types: + +```ts +interface UserParams { + userId: string; +} + +const userRoute = createRoute('/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. + +It is recommended to user +[`paramsAdapter`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#paramsAdapter) to constrain +route params at runtime: + +```ts +import * as d from 'doubter'; + +const userRoute = createRoute({ + pathname: '/user/:userId', + paramsAdapter: d.object({ + userId: d.string() + }) +}); +``` + +Read more about param adapters in the [Route params](#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: ```tsx import { useNavigation } from 'react-corsair'; -export default function UserPage() { +function TeamPage() { const navigation = useNavigation(); + + return ( + + ); +} +``` + +Here, [`navigation.push`](https://smikhalevski.github.io/react-corsair/classes/Navigation.html#push) triggers +[`RouterProps.onPush`](https://smikhalevski.github.io/react-corsair/interfaces/RouterProps.html#onPush) with +the location of `userRoute`. + +If user `userRoute` has [params](#route-params), then you must first create a location with that params: + +```ts +navigation.push(userRoute.getLocation({ userId: 42 })); +``` + +# Code splitting + +To enable code splitting in your app, use +the [`lazyComponent`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#lazyComponent) option, +instead of the [`component`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#component): + +```ts +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`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#loadingComponent) +option to your route: + +```ts +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`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#loadingAppearance) +option: + +```ts +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](#error-boundaries) is rendered and `Router` +would retry loading the component again later. + +# Error boundaries + +Each route has a built-in +[error boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary). When an +error occurs during component rendering, +an [`errorComponent`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#errorComponent) is +rendered: + +```ts +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: + +```ts +import { userRouteState } from 'react-corsair'; + +function ErrorFallback() { + const { error } = userRouteState(userRoute); - return 'Hello'; + 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`](https://smikhalevski.github.io/react-corsair/functions/notFound.html) function: + +```ts +import { useRouteState } from 'react-corsair'; + +function UserPage() { + const { params } = useRouteState() + const user = useUser(params.userId); + + if (!user) { + notFound(); + } + + return 'Hello, ' + user.firstName; } ``` -Navigation allows to push to a new route: +`notFound` throws a [`NotFoundError`](https://smikhalevski.github.io/react-corsair/classes/NotFoundError.html) that +triggers an [error boundary](#error-boundaries) and causes `Router` to render +a [`notFoundComponent`](https://smikhalevski.github.io/react-corsair/interfaces/RouteOptions.html#notFoundComponent): ```ts -navigation.push(userRoute); +function UserNotFound() { + return 'User not found'; +} + +const userRoute = createRoute({ + pathname: '/user/:userId', + component: UserPage, + notFoundComponent: UserNotFound +}); +``` + +# History integration + +React Corsair provides history integration: + +```tsx +import { Router, createBrowserHistory, useHistorySubscription } from 'react-corsair'; +import { userRoute } from './routes'; + +const history = createBrowserHistory(); + +function App() { + useHistorySubscription(history); + + return ( + + ); +} ```