r.obin.ch

Private and protected routes with React Router

React Router is one of the most widely used tools to implement client-side routing in React. In the upcoming version 6, some things will change, as the migration guide reveals.

One use case that can be repeatedly encountered is checking an authorization and forwarding it accordingly. For example, a user wants to access the path ./admin/dashboard. If the user is suitably authenticated and authorized, the dashboard is displayed, otherwise the login form is displayed. After a successful login, the user should be redirected to the original requested path.

To get a feel for the new React Router version, a migration from version 5 to 6 shall be reproduced with the mentioned use case.

React Router v5

To implement the described use case, the <Route> component of react-router is extended. If we use React functionally, we are actually talking about a composition rather than an extension.

import { useEffect } from 'react';
import { Redirect, Route, RouteProps, useLocation } from 'react-router';

export type ProtectedRouteProps = {
  // Is the user authenticated?
  isAuthenticated: boolean;
  // Path to the sign in form
  authenticationPath: string;
  // Redirect path after authentication
  redirectPath: string;
  // Function to update the redirection path
  setRedirectPath: (path: string) => void;
} & RouteProps;

export default function ProtectedRoute({ isAuthenticated, authenticationPath, redirectPath, setRedirectPath, ...routeProps }: ProtectedRouteProps) {
  const currentLocation = useLocation();

  // If the caller is not authenticated, save the current path.
  // Because we define the state of another component, this must happen in an effect.
  useEffect(() => {
    if (!isAuthenticated) {
      setRedirectPath(currentLocation.pathname);
    }
  }, [isAuthenticated, setRedirectPath, currentLocation]);

  // If the caller is authenticated and no forwarding is necessary, render the requested route.
  if (isAuthenticated && redirectPath === currentLocation.pathname) {
    return <Route {...routeProps} />;
  } else {
    // If redirection is necessary, redirect to the path before authentication or to the login form, as appropriate.
    return <Redirect to={{ pathname: isAuthenticated ? redirectPath : authenticationPath }} />;
  }
}

The path for any forwarding and whether the caller is authenticated is stored in the application state. Possibly Redux or another library is used for this. To keep the example simple, we will keep this simple and store it in a React Context.

To do this, we will first define a model that describes how the data structure of the React Context looks like.

export type Session = {
  isAuthenticated?: boolean;
  redirectPath: string;
};

export const initialSession: Session = {
  redirectPath: "",
};

To use the React Context the following component and hook is provided.

import { createContext, useContext, useState } from "react";
import { initialSession, Session } from "../models/session";

export const SessionContext = createContext<[Session, (session: Session) => void]>([initialSession, () => {}]);
export const useSessionContext = () => useContext(SessionContext);

export const SessionContextProvider: React.FC = (props) => {
  const [sessionState, setSessionState] = useState(initialSession);
  const defaultSessionContext: [Session, typeof setSessionState]  = [sessionState, setSessionState];

  return (
    <SessionContext.Provider value={defaultSessionContext}>
      {props.children}
    </SessionContext.Provider>
  );

This React Context is now to be made available to the application.

ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>
      <SessionContextProvider>
        <App />
      </SessionContextProvider>
    </BrowserRouter>
  </React.StrictMode>,
  document.getElementById('root'),
);

Now the <ProtectedRoute> can be used for paths which need authentication. Here is an example with different routes:

import ProtectedRoute, { ProtectedRouteProps } from '../components/ProtectedRoute';
import { useSessionContext } from '../contexts/SessionContext';
import { Route, Switch } from 'react-router';
import Homepage from './Homepage';
import Dashboard from './Dashboard';
import Protected from './Protected';
import Login from './Login';

export default function App() {
  const [sessionContext, updateSessionContext] = useSessionContext();

  const setRedirectPath = (path: string) => {
    updateSessionContext({ ...sessionContext, redirectPath: path });
  };

  const defaultProtectedRouteProps: ProtectedRouteProps = {
    isAuthenticated: !!sessionContext.isAuthenticated,
    authenticationPath: '/login',
    redirectPath: sessionContext.redirectPath,
    setRedirectPath: setRedirectPath,
  };

  return (
    <div>
      <Switch>
        <Route exact={true} path="/" component={Homepage} />
        <ProtectedRoute {...defaultProtectedRouteProps} path="/dashboard" component={Dashboard} />
        <ProtectedRoute {...defaultProtectedRouteProps} path="/protected" component={Protected} />
        <Route path="/login" component={Login} />
      </Switch>
    </div>
  );
}

The full example is available here.

React Router v6

The migration to version 6 is quite simple.

First, the react-router and react-router-dom packages would need to be updated. The @types/react-router-dom package is no longer needed, since the type definition is now integrated in react-router-dom.

In the <ProtectedRoute> component, only <Redirect> needs to be replaced with <Navigate>. The to property is preserved.

The rest remains the same, except the application of <ProtectedRoute> (and also the original <Route>).

import ProtectedRoute, { ProtectedRouteProps } from '../components/ProtectedRoute';
import { useSessionContext } from '../contexts/SessionContext';
import { Route, Routes } from 'react-router';
import Homepage from './Homepage';
import Dashboard from './Dashboard';
import Protected from './Protected';
import Login from './Login';

export default function App() {
  const [sessionContext, updateSessionContext] = useSessionContext();

  const setRedirectPath = (path: string) => {
    updateSessionContext({ ...sessionContext, redirectPath: path });
  };

  const defaultProtectedRouteProps: ProtectedRouteProps = {
    isAuthenticated: !!sessionContext.isAuthenticated,
    authenticationPath: '/login',
    redirectPath: sessionContext.redirectPath,
    setRedirectPath: setRedirectPath,
  };

  return (
    <div>
      <Routes>
        <Route path="/">
          <Homepage />
        </Route>
        <ProtectedRoute {...defaultProtectedRouteProps} path="dashboard">
          <Dashboard />
        </ProtectedRoute>
        <ProtectedRoute {...defaultProtectedRouteProps} path="protected">
          <Protected />
        </ProtectedRoute>
        <ProtectedRoute {...defaultProtectedRouteProps} path="nested">
          <Route path="one">
            <Protected />
          </Route>
          <Route path="two">
            <Protected />
          </Route>
        </ProtectedRoute>
        <Route path="login">
          <Login />
        </Route>
      </Routes>
    </div>
  );
}

It is obvious that in the routes the target component is no longer given as a property, but as a child element. This works the same way with our <ProtectedRoute>. <Switch> has been replaced by <Routes>. Nesting is not a problem either.

So all in all, the migration is done quickly. In the same repository the example can be found after the migration. Here is the link to it.

Closing words

The migration was not difficult. Especially the guide for the migration gives a quick overview of the changes.

In my opinion, React Router has been improved in the right places. I like the easier nesting and also the router outlet feature.