0.2.0 • Published 3 years ago

pre-router v0.2.0

Weekly downloads
6
License
MIT
Repository
github
Last release
3 years ago

pre-router

pre-router is a router for React with code and data preloading at its core.

pre-router allows you to specify for each route the component to render and the data to preload. Then, the code and data for the matching routes start loading in parallel right after the path changes, so even before rendering begins. This means that implementing the "Render-as-You-Fetch" pattern is very natural with pre-router.

Once we begin rendering the matching routes, if the code or data has not finished loading for a route, then it will suspend until code and data are loaded, showing a fallback in the meantime.

pre-router also gives you the ability to start loading code and data even before the user clicks on a link. If the user hovers over a link, there's a chance that they'll click it, so we could start loading the code for that route as soon as the user hovers. And if the user presses the mouse down on a link, there's a very good chance that they'll complete the click, so we could also start loading the data for the route as soon as the user presses in on the link.

Table of Contents

Installation

Using yarn:

yarn add pre-router suspendable

Using npm:

npm install pre-router suspendable

Note that you also need to install suspendable in order to use lazyComponent for route components. lazyComponent is similar to React.lazy but has a few important differences like being able to start loading the component even before rendering begins and clear any errors after a component fails to load. You can read more about the differences in the suspendable README.

Usage

Preloading data

First, for each route that requires some data, we need to specify a function for preloading that data, which returns the preloaded data in the form of a resource that the route component can read synchronously, suspending if it's not ready yet.

Here's a simple example using lazyResource from suspendable:

const preloadPost = (slug: string) => {
  const resource = lazyResource(() => fetchPostBySlug(slug));

  resource.load();

  return resource;
};

However, you can use whatever library you want to preload data, as long as it's compatible with React Suspense, i.e., it allows creating resources that make the component suspend if the resource is not ready yet.

If you want to use a fetching library that is not compatible with React Suspense, you can still make it compatible by using the utils provided by suspendable.

Route components

For each route, we need to specify a component, which needs to be the default export. For example:

const PostPage: RouteComponent<PreloadedPost> = ({ preloadedData, params }) => {
  const post = preloadedData.read();

  if (!post) {
    return <h1>Post with slug '{params.slug}' not found.</h1>;
  }

  return <h1>{post.title}</h1>;
};

export default PostPage;

Defining routes

Next, we define all our routes by specifying for each the path, the component to use, and the data to preload:

const routes: Route[] = [
  {
    path: '/',
    component: lazyComponent(() => import('./HomePage')),
  },
  {
    path: '/profile',
    component: lazyComponent(() => import('./ProfilePage')),
    preloadData: () => preloadProfileData(),
  },
  {
    path: '/posts/:slug',
    component: lazyComponent(() => import('./PostPage')),
    preloadData: ({ slug }) => preloadPost(slug),
  },
  {
    component: lazyComponent(() => import('./Page404')),
  },
];

Notice that for the last route (the 404 route), we didn't specify a path so that it always matches.

pre-router matches routes starting from the first one and as soon as it finds a match, it stops. So, if no route matches a path, we end up reaching the 404 route, which always matches since it has no path specified.

Even though all routes can be specified top-level, most apps have a container around the content that often consists of the header and the footer, which might even require some data (e.g., showing the signed in user in the header). In that case, we should have a <Root> component. For example:

const Root: RouteComponent = ({ children }) => (
  <>
    <Header />
    <Content>{children}</Content>
    <Footer />
  </>
);

export default Root;

and define it as the top-level route that always matches (so, no path is specified) with all the other routes defined as child routes:

const routes: Route[] = [
  {
    component: lazyComponent(() => import('./Root')),
    preloadData: () => preloadRootData(), // optionally preload data needed in header/footer
    routes: [
      {
        path: '/',
        component: lazyComponent(() => import('./HomePage')),
      },
      // ...
    ],
  },
];

Rendering routes

Finally, to render the routes, we create a router and pass it to the <PreRouter> component:

const router = createRouter(routes);

const App = () => <PreRouter router={router} />;

Try it on CodeSandbox

API

createRouter

createRouter(routes: Route[], options?: RouterOptions): Router

Parameters

  • routes: Route[] is an array with the definition of all routes. A Route object consists of the following properties:

    • path?: string The path for which this route will match. Path parameters, even with custom regular expressions, are supported. For example, /profile, /posts/:slug, and /@:username([a-z]+) are all valid paths. If no path is specified, then this route will always match. This can be used for the 404 route for example. When pre-router matches routes against a path, it stops at the first match on each level. So, if you have routes with overllaping paths, e.g., /about and /:username, place the route with the more specific path, i.e., /about, before the route the generic path, i.e., /:username.
    • preloadData?: (params: Record<string, string>) => any is the function used to preload data for the route whenever it matches. This function is called with the route parameters and it should return the preloaded data in the form of a resource that the route component can attempt to read and if it's not ready yet, the component suspends.
    • component: RouteComponent is the component to render for the route. The component that is specified should be wrapped in lazyComponent so that it is code-split and it will start loading only when the route matches, in parallel with the data. (Remember that the component needs to be the default export when using lazyComponent.) This component will be passed the following props:
      • preloadedData is the preloaded data returned by preloadData. If preloadData is not specified for a route, then preloadedData is undefined.
      • params The values of the dynamic parameters in the path, if any. For example, if path is /post/:slug, then params could be { slug: 'an-interesting-post' }.
      • children Any matching child routes that should be rendered inside the parent route.
    • fallback?: ComponentType is the optional fallback component that will be shown while the component or data for the route are still loading.
    • routes?: Route[] any children routes of the current route.

Return value

Description

createRouter is used to create a router with the specified routes. The main properties in the definition of each route are the path, the function to preload the data for the route, and the component to render for the route. The created Router object can then be passed to the <PreRouter> component.

Example

const router = createRouter([
  {
    path: '/',
    component: lazyComponent(() => import('./components/HomePage'), {
      autoRetry: true,
    }),
  },
  {
    path: '/posts/:slug',
    component: lazyComponent(() => import('./components/PostPage'), {
      autoRetry: true,
    }),
    preloadData: ({ slug }) => preloadPost(slug),
  },
  {
    component: lazyComponent(() => import('./components/Page404'), {
      autoRetry: true,
    }),
  },
]);

RouterOptions

interface RouterOptions {
  defaultFallback?: ComponentType;
  errorFallback?: ComponentType<{ error: Error; retry: () => void }>;
  history?: RouterHistoryOption;
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
  preloadOnLinkHover?: PreloadContentOption;
  preloadOnLinkPressIn?: PreloadContentOption;
  useTransition?: UseTransition;
}
  • defaultFallback?: ComponentType is the default fallback component to use for any route that didn't specify a custom fallback. The default fallback will be shown while the component or data for the route are loading.

  • errorFallback?: ComponentType<{ error: Error; retry: () => void }> is the error fallback component to show when an error occurs for some route, e.g., when the component or data for a route fails to load. This component is given two props: error and retry. error is the Error object and retry is a callback to retry loading the failed route. Generally, you should include a Retry button in the error fallback, and pass retry as its onClick prop.

  • history?: RouterHistoryOption (default: 'browser') specifies the type of history object to use for route navigation. The history object is created with the history package. RouterHistoryOption can be one of the following:

    • 'browser' creates a BrowserHistory that uses the History API. The current route's path will be the actual path in the URL. For example, if the domain is https://example.com and the route path is /profile, then the URL for the page will be https://example.com/profile.
    • 'hash' creates a HashHistory. The current route's path will be in the hash portion of the URL. For example, if the domain is https://example.com, the path under which the page loads is /, and the route path is /profile, then the URL for the page will be https://example.com/#/profile.
    • 'memory' creates a MemoryHistory. The current route's path will be stored in memory, so no URL changes will be happening as the user navigates from one route to another. Memory history should be used in non-browser environments, such as React Native.
    • ['memory', MemoryHistoryOptions] creates a MemoryHistory, just like for the 'memory' option, but also allows to specify MemoryHistoryOptions, which consists of the following properties:
      • initialEntries?: InitialEntry[] are the initial entries in the history stack, e.g., ['/', '/photos', '/profile'].
      • initialIndex?: number is the index of the initial entry. By default it's the index of the last entry.
  • onError?: (error: Error, errorInfo: ErrorInfo) => void is a callback that is called when an error occurs for some route, e.g., when the component or data for a route fails to load. This callback can be used to log the error information to an error reporting service.

  • preloadOnLinkHover?: PreloadContentOption (default: 'code') is the content to preload for a link's path whenever the link is hovered. See PreloadContentOption.

  • preloadOnLinkPressIn?: PreloadContentOption (default: 'code-and-data') is the content to preload for a link's path whenever the link is pressed in (mouse down on desktop). See PreloadContentOption.

  • useTransition?: UseTransition is the useTransition hook exported by React. At the moment, useTransition is only available in the experimental releases of React, and you need to import it as unstable_useTransition. Furthermore, you need to have React Concurrent Mode enabled for useTransition to work (read more about adopting React Concurrent Mode). When you pass the useTransition hook, you opt into having a delay during route updates in order to avoid showing undesirable loading states. If we perform a route update without a transition, the new route will render immediately and very likely suspend, showing its fallback component to the user in place of the old route's content. By performing the route update with a transition, we can defer the display of the new route and show the old route while new one is loading. You can use the useRouteTransitionPending hook to know when a route transition is pending and show some sort of loading indicator (in the page header for example) so that the user knows that a route update is actually occuring while still seeing the old route.

PreloadContentOption

type PreloadContentOption = 'code' | 'code-and-data' | 'none';

PreloadContentOption is used to specify what type of content to preload for a path even before the navigation to that path occurs. It can be one of the following:

  • 'code' Preload only the code associated with the matching routes, i.e., the components. Note that the code is only loaded once, so it will not be loaded again if it already loaded or is loading.
  • 'code-and-data' Preload both the code and the data associated with the matching routes.
  • 'none Do not preload anything.

Router

interface Router {
  disableNextRouteTransition: () => void;
  enableNextRouteTransition: () => void;
  getCurrentRouterEntry: () => RouterEntry;
  history: BrowserHistory | MemoryHistory | HashHistory;
  isNextRouteTransitionEnabled: () => boolean;
  options: Required<RouterOptions>;
  preloadBeforeNavigation: (path: string, content: PreloadContentOption) => void;
  refreshCurrentRouterEntry: () => void;
  removeHistoryListener: () => void;
  subscribe: (subscriber: (routerEntry: RouterEntry) => void) => () => void;
}
  • disableNextRouteTransition: () => void; disables the transition for the next route update. This can be useful when having a transition for the next route update is unwanted because rendering the old route while the new one is loading will result in rendering no longer valid preloaded data, so it's preferrable to immediately render the new route, even if it means to show a loading indicator to the user. This function will disable the transition only for the next route update - once the route updates, transitions are enabled again. This is only applicable when you opt into route transitions by passing the useTransition option.

  • enableNextRouteTransition: () => void; enables the transition for the next route update. Transitions are already enabled by default, so calling this function would only make sense to undo a call to disableNextRouteTransition before the route updates. Since disableNextRouteTransition disables the transition only for the next route update, there's no need to call enableNextRouteTransition after the route updates since transitions are enabled again automatically. This is only applicable when you opt into route transitions by passing the useTransition option.

  • getCurrentRouterEntry: () => RouterEntry returns the current router entry, which consists of the location object and the preloaded routes for that location.

  • history: BrowserHistory | MemoryHistory | HashHistory; is the history object created with the history package, which provides the primitives for route navigation. This history object could be an instance of BrowserHistory, MemoryHistory, or HashHistory, depending on the history option.

  • isNextRouteTransitionEnabled: () => boolean; returns whether the transition for the next route is enabled, i.e., whether there will be a delay in the next route update in order to avoid undesirable loading states. This is only applicable when you opt into route transitions by passing the useTransition option.

  • options: Required<RouterOptions> are the options provided to createRouter populated with default values for all options that were omitted.

  • preloadBeforeNavigation: (path: string, content: PreloadContentOption) => void; preloads the specified content for the given path before the navigation to that path actually occurs. This could be used to start loading code and data for a route even before the user navigates to it, if we know that the user will likely navigate to it. For example, we could start preloading content for a route in an event handler. Note that if the specified content is already loaded or is loading for the given path, then this function has no effect.

  • refreshCurrentRouterEntry: () => void refreshes the current router entry by preloading again the components and data for the current entry. Note that if the components already loaded or are still loading, then preloading them again will have no effect.

  • removeHistoryListener: () => void removes the history listener that the router attached to the history object when it was created. In most applications, we create a router on the startup of the application and use it throughout the entire session. However, if for some reason you need to create another router and replace the original one, you should call this method on the original router object so that it no longer listens to history changes.

  • subscribe: (subscriber: (routerEntry: RouterEntry) => void) => () => void; subscribes the given callback function to router entry changes. Every time a router entry changes, e.g., when the user clicks on a link or navigates back/forth in history, the current router entry is updated with the new location and the preloaded routes for that location, and the specified callback will be called with this new router entry. A cleanup function is returned that will unsubscribe the callback when called.

<PreRouter>

<PreRouter> is the component that is responsible for rendering the routes of your app. It accepts a single router prop, which is the Router object that was creating with createRouter.

Example

const router = createRouter(routes, options);

const App = () => <PreRouter router={router} />;

<Link>

<Link> is the component that is used to create internal links. It accepts a to prop specifying a path, and any other prop that an <a> element would accept (except href).

Example

<Link to="/profile">Profile</Link>

<NavLink>

<NavLink> behaves in the same way as <Link> but it adds an active class to the className of the rendered <a> element whenever the current path matches the path specified as the to prop. This allows you to add custom styles to the link when it points to the same path as that of the current page.

Example with styled-components

const MenuLink = styled(NavLink)`
  color: grey;

  &.active {
    color: green;
  }
`;

const Menu = () => (
  <>
    <MenuLink to="/profile">Profile</MenuLink>
    <MenuLink to="/posts">Posts</MenuLink>
  </>
);

<Redirect>

<Redirect> is the component that can be used to perform an internal redirect. It accepts a to prop specifying the destination path. By default, the new destination will override the current entry in the history stack. If you instead want the new destination to be added to the stack rather than replacing the current enty, you can pass a push prop with a value of true.

Example

const ProfilePage = ({ signedInUser }) => {
  if (!signedInUser) {
    return <Redirect to="/sign-in" />;
  }

  return <h1>Welcome {signedInUser.name}</h1>;
};

useRouter

The useRouter hook returns the Router object that was specified as the router prop to <PreRouter>.

usePathname

The usePathname hook returns the current location's pathname.

useRouteTransitionPending

The useRouteTransitionPending hook returns a boolean indicating whether a route transition is pending. This allows you to show a loading indicator (in the page header for example) so that the user knows that a route update is actually occuring while still seeing the old route. This is only applicable when you opt into route transitions by passing the useTransition option.

Prior art

pre-router was inspired by the router of the Relay Hooks Example App.

Contributing

Pull requests are very welcome. If you intend to introduce a major change, please open a related issue first in which we can discuss what you would like to change.

Please make sure to update the tests and the README as appropriate.

License

MIT