react-router-manager

React Router server side rendering with data fetching

Usage no npm install needed!

<script type="module">
  import reactRouterManager from 'https://cdn.skypack.dev/react-router-manager';
</script>

README

react-router-manager

React Router server side rendering with data fetching. This module is based on react-router-config and expands on the same concepts. The goal is to create a one stop solution for routing and data fetching on both client and server. It is particularly useful for people migrating from redial.

Installation

npm install --save react-router-manager

Usage

There are 2 distinct stages to working with this library, the first is data fetching and the second is rendering. In order for both to work you need to create "route configs". These configs allow for both routing and data fetching to work. Please use them for every route that needs to fetch data.

IMPORTANT: the utilities here default to exact: true for route matching, which is the opposite of what Route components in React Router do. Partial matching is prone to errors and creates more issues than it solves. Override it on per route basis if needed.

Route configs

A route config is an array of objects like this:

[
  // A Route
  {
    // The only difference with default Route paths is that you need
    // to use "*" for match-all data fetching and intercept functions.
    path: '/stuff/:id?',

    // This will be called both before the route data is fetched and before the route is rendered.
    // It allows you to completely rewrite the route object and return different components
    // or redirect conditionally. Return a new route configuration object here.
    // Anything except `path` and `intercept` can be returned (overriden).
    intercept: ({ match, location, route }) => {
      // Returning null will prevent fetching and rendering of the entire subtree.
      if (!isAuthorized()) return null;

      // Simply return a Route or a Redirect configuration object.
      if (match.params.id === '0') return { to: '/otherstuff' };
      return { component: MyComponent };
    },

    // These props will be passed on to the component and also available in the data fetching function
    props: {
      type: 'awesome',
    },

    // Component to render
    component: MyComponent,

    // You may create your own render function if you wish. It will receive the usual arguments
    // along with `children` which is the rendered nested routes for this route (or null).
    // NOTE: This way data fetching will not work for this route as it relies on `component` prop.
    render: ({ match, location, history, staticContext, children }) => {
      return <MyComponent>{children}</MyComponent>;
    }

    // Useful for handling 404s, but you can use it in any case.
    statusCode: 404,

    // You can nest routes like this.
    routes: [
      // {...}
    ],

    // Any other props you would pass to a Route
    // We default this to `true` unlike the React Router. Pass `false` to override.
    exact: false,
    // ...
  },

  // A redirect
  {
    // Set this statusCode for the redirect
    statusCode: 302,

    // Any other props you would pass to a Redirect
    from: '/this/:id',
    to: '/that/:id',
    // ...
  },

  // To use syntax like this also use a `.filter` function at the end
  myCondition && {
    from: '/old',
    to: '/new',
  },

  //...
]
// Use this if you had any conditions, any falsy values in these arrays will get filtered out
.filter(Boolean)

Every route must have either component, render or intercept for rendering. For data fetching to work component is required. Everything else is optional.

Connecting components

To specify a data fetching function you need to create a static resolver method:

// You can return either a promise or an array of promises
MyComponent.resolver = ({ match, route }) => ([
  getMyAsyncDataAndReturnPromise(match.params, route.props.type),
  andAnotherPromise(match.params),
]);
export default MyComponent;

runResolver

When you are ready to collect your promises and fetch the data use runResolver:

import { runResolver } from './modules/resolver';
const routes = [/* routes config */];
const { location } = history;

// By default each static resolver method gets { match, route, location } object as an argument.
// You can modify that object here as you wish.
const getLocals = (request) => ({ ...request, store: reduxStore })

// You get back an array of promises to resolve as you will
Promise.all(runResolver(routes, location, getLocals));

You might want to use Promise.allSettled on the server side to wait for every promise to resolve or reject. Or you can go for a "fail early" approach with Promise.all everywhere. Returning an array gives you that choice.

RouterManager or renderRoutes

This simply renders the route configuration for you. This is the default export and you can use it as a function or as a component. Pass directly into React Router like this:

import RouterManager from 'react-router-manager';
import { Router } from 'react-router';
import { createBrowserHistory } from 'history';
import { hydrate } from 'react-dom';

const routes = [/* routes config */];

hydrate(
  <Router history={createBrowserHistory()}><RouterManager routes={routes} /></Router>,
  document.getElementById('root')
);

Alternatively:

import renderRoutes from 'react-router-manager';
// ...
hydrate(
  <Router history={createBrowserHistory()}>{renderRoutes({ routes })}</Router>,
  document.getElementById('root')
);

Utility functions

These are used internally, but you might want to use them too.

injectStatusCode

import { injectStatusCode } from 'react-router-manager';

const MyComponent = (props) => {
  // Simply injects statusCode when static context is available. Use in any Component where
  // route information is available.
  injectStatusCode(props.staticContext, 403);
  //...
};

RouteStatus

import { RouteStatus } from 'react-router-manager';

const MyComponent = (props) => {
  return (
    // This is essentially the same as injectStatusCode but in a Route object form.
    <RouteStatus statusCode={403}>
      {/* content */}
    </RouteStatus>
  );
};

Route

You can use it as a function or as a component.

import { Route } from 'react-router-manager';

// Will simply render a single route
return <Route path="/stuff/:id?" component={MyComponent} />;

Alternatively:

import { Route as renderRoute } from 'react-router-manager';

// Will simply render a single route
return renderRoute({ path: '/stuff/:id?', component: MyComponent });

Redirect

You can use it as a function or as a component.

import { Redirect } from 'react-router-manager';

// Will simply render a single redirect
return <Redirect from="/this/:id" to="/that/:id" />;

Alternatively:

import { Redirect as renderRedirect } from 'react-router-manager';

// Will simply render a single route
return renderRedirect({ from: '/this/:id', to: '/that/:id' });

A complete example

On the client side:

import { render, hydrate } from 'react-dom';

import { Router } from 'react-router';
import { createBrowserHistory } from 'history';
import RouterManager, { runResolver } from 'react-router-manager';

import routes from './routes';


// You may want to disable prerendering during development
const isAppPrerendered = global.__APP_PRERENDERED__;

const renderPage = (history, routes) => {
  const content = <Router history={history}><RouterManager routes={routes} /></Router>

  const renderer = isAppPrerendered ? hydrate : render;
  return renderer(content, document.getElementById('main'));
};

const startRouter = (store, history) => {
  let shouldFetch = !isAppPrerendered;

  const handleFetch = (location) => {
    if (shouldFetch) runResolver(routes, location);
    shouldFetch = true;
  };

  // This allows to fetch data after navigation. You may use a different strategy if you wish.
  history.listen(handleFetch);
  handleFetch(history.location);

  renderPage(store, history, routes);
};

// Call setup functions. First setup store, then initialize router.
const history = createBrowserHistory();
startRouter(store, history);

An expressjs middleware on the server side:

import { renderToString } from 'react-dom/server';

import { StaticRouter } from 'react-router';
import { createLocation } from 'history';
import RouterManager, { runResolver } from 'react-router-manager';

import routes from './client/routes';


const renderContent = (routes, context, location) => {
  const content = (
    <StaticRouter {...{ context, location }}><RouterManager routes={routes} /></StaticRouter>
  );
  return renderToString(content);
};

export default (req, res) => {
  const location = createLocation(req.url);

  const matchPage = (result, error) => {
    const data = error ? {} : result;
    const context = {};
    const content = renderContent(routes, context, req.url);

    if (context.url) return res.redirect(context.statusCode || 301, context.url);
    // In your template serialize data into `__APP_PRERENDERED__`;
    res.status(context.statusCode || 200).render('index', { data, content });
  };

  const handleError = (error) => {
    console.error(`Request ${req.url} failed to fetch data:`, error);
    matchPage(null, error);
  };

  runResolver(routes, location).then(matchPage).catch(handleError);
};