Chapter 17 Client-Side Routing

This chapter discusses how to use React to effectively develop Single Page Applications (SPA)—web applications that are located on a single web page (HTML file), but DOM manipulation (and often AJAX requests) to produce the appearance of multiple “web pages”. This structure is facilitated by the use of the client-side routing library react-router, which allows you to render different Components based on the browser’s URL, allowing each View (“page”) to be treated as a unique resource.

17.1 Single-Page Applications

As you’ve seen in previous chapters, the React framework lets you dynamically render different Views (Components) based on different conditions such as the state of the app. For example, you can have a blogging app that could have a blogPostId state variable, and then use that variable to determine which blog post to display. Often these Views act as entirely separate pages—you either show one View or an another. As such, you’d often like each View to be treated as an individual resource and so to have its own URI, thus allowing each View to be referenced individually. For example, each blog post could have it’s own URI, allowing a user to type in a particular URL to see a specific post (and letting that user share the post with others).

In order to achieve this effect, you can use client-side routing. With client-side routing, determining which View to display based on the URL (how to “route”, or map that URL to the correct resource) is performed on the client-side by JavaScript code. This is distinct from server-side routing, in that the server isn’t deciding which resource to show (i.e., which .html file to respond to a request with), but rather responds with a single HTML file whose JavaScript dynamically determines what resource to show (i.e., which React component to render) based on the URI that request was sent to!

  • In this context, “routing” involves taking the resource identifier (the URI) and determining what representation of that resource should be displayed—what View to show. A “route” is thus a URI, which will refer to a particular View of the resource.

Client-side routing allows you to have unique URLs for each View, but will also make the app work faster—instead of needing to download an entire brand new page from the server, you only need to download the requisite extra data (e.g., using an AJAX request), with much of the other content (the HTML, CSS, etc) already being in place. Moreover, this will all your app to easily share both state data and particular components (e.g., headers, navigation, etc).

  • Google Drive is a good example of a Single-Page Application. Notice how if you navigate to a new folder, the URL changes (so you can link to individual folders), but only a single “pane” of the page changes.

Because React applications are component-based, you can perform client-side routing in React by using conditional rendering to only render components if the current route is correct. This follows a structure similar to:

function App(props) {
    //pick a component based on the URL
    let componentToRender = null;
    if(currentUrl === '/home'){ //pseudocode comparison with URL
        componentToRender = <HomePage />;
    }
    else if(currentUrl === '/about'){
        componentToRender = <AboutPage />;
    }
    //render that component
    return componentToRender;
}

That is, if the current URL matches a particular route, then the Component will be rendered.

17.2 React-Router

Third-party libraries such as React Router provide Components that include this “matching” functionality, allowing you to easily develop single-page applications.

This chapter details how to use version 6 of React Router, released in November 2021. This version is significantly different from the previous versions (including v5). Be careful when looking up examples and resources that they’re utilizing the same version as you! For details about earlier versions of the React Router, see the documentation for those versions

As with other libraries, you begin using React Router by installing the react-router-dom library (the browser-specific version of React Router):

npm install react-router-dom

You will then need to import any Components you wish to use into the .js files containing your React code. For example:

//import BrowserRouter, Routes, Route, and Link from react-router
import { BrowserRouter, Routes, Route, Link} from 'react-router-dom'

These Components are described in the following sections.

Routing

The <BrowserRouter> Component (which is often imported with an alias of <Router>—though there is also a <Router> component!) is the “base” Component used by React Router. This Component does all the work of keeping the React app’s UI (e.g., which Components are rendered) in sync with the browser’s URL. The BrowserRouter “listens” for changes to the URL, and then passes information about the current route (called the path) to its child components. This allows each child to always know what route is currently shown in the URL, without needing to access the URL directly.

  • With React Router, a “route” is defined by the path portion of a URI (see Chapter 2). This is the part that comes after the protocol and domain (e.g., after the https://mydomain.com/). Thus the /home route would refer to the URI https://mydomain.com/home, while the /about route would refer to the URI https://mydomain.com/about.

  • BrowserRouter utilizes the HTML5 history API to interact with the brower’s URL and history (what allows you to go “back” and “forward” between URLs). This API is supported by modern browsers, but older browsers (i.e., IE 9) would need to use <HashRouter> as an drop-in alternate. HashRouter uses the fragment identifier portion of the URI to track what “page” the app should be showing, causing URL’s to include an extra hash # symbol in them (e.g., https://mydomain.com/#/about).

  • Your app will only ever have a single <BrowserRouter> component in it–usually at the “top level” of your application (so it would contain <App> as a child). Thus the <BrowserRouter> is usually rendered in the index.html file:

    //index.js
    
    import { BrowserRouter } from 'react-router-dom'
    import App from './components/App.js'
    
    //render the App *inside* of the BrowserRouter
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(
        <BrowserRouter>
            <App />
        </BrowserRouter>
    );

Inside the <BrowserRouter> (usually inside of the <App>), you will define the routes for your application—the collection of supported paths and which View to show at each. You can specify route-based views using the <Route> Component. This component will render its element only when the current URL matches a specified path. In effect, the Route Component handles checking if the current URL matches the specified path, and if so renders its element. If the URL doesn’t match the route, then the element is not rendered. Both the path and the element are passed into the <Route> as props:

function App(props) {
    return (
        <Routes> {/* the collection of routes to match */}
            {/* if currentUrlPath === "home" */}
            <Route path="home" element={<HomePage />} />

            {/* if currentUrlPath ===  "about" */}
            <Route path="about" element={<AboutPage />} />
        </Routes>
    );
}

Points to notice about this example:

  • The path prop is used to indicate the route that you wish to match—in particular, matching to the path part of the URL (after the domain). You do not include http or domain.com in the path.

    The “root” segment "/" is used to match to a URL without a path (e.g., what to show at http://domain.com). Using a wildcard * in the path will match to “anything”, and is good for rendering “Page Not Found” elements.

    The path can include multiple segments (e.g., assignments/react); however multiple segments usually correspond to nested routes, described below.

  • The Component (View) to render is passed in as the element prop. You instantiate the component using <Component/> syntax, and then that is passed as the inline expression (inside the {}). Yes, it makes the syntax look awkward.

    You can of course pass additional props to the rendered component as normal:

    <Route path="profile" element={<ProfilePage user={userData} />} />

    While it is possible to pass nested elements as the element property, that can quickly become a readability nightmare. Better practice is to define a single “wrapper” Component to be rendered at the Route.

  • All <Route> elements are made children of a single element called <Routes> (note the s makes it plural!). The <Routes> element represents the “collection” of Routes that the Router needs choose between when deciding what Component to render (if any). You can think of it as acting like a switch statement.

    In practice, you will have a single <Routes> element in your page, usually in a top-level component such as <App>.

    Note that a <Routes> can only have <Route> elements as children, and a <Route> element can only be the child of a <Routes>. They go together, and nothing else (no other <div>, etc. elements) can come between them. Though see Nesting Routes before for details on integrating different DOM layouts with your routing.

Nesting Routes

In the above example, the Route’s path prop corresponds to a single segment of the URI path. Many React apps will want to differentiate the rendered content based on multiple segments. For example: the /user/profile path might show a <UserProfile> component, while the user/favorites path might show a <FavoriteItems> component (with the list of items the user has marked). Yet both of these paths might also want to show content shared by all Views that are part /user paths (but different from other non-user-specific paths, such as the /items path).

React Router supports this behavior by nesting routes—having one <Route> element be the child of another. For example:

// An example of nested routes
<Routes>
    <Route path="user" element={<UserLayout />} >
        <Route path="profile" element={<UserProfile />} />
        <Route path="favorites" element={<FavoriteItems />} />
    </Route>
    <Route path="items" element={ <ItemList />} />
</Routes>

When the <Routes> element goes to match a URL and determine which element to render, it will start with with the first segment of the path, rendering that element. For example, if the path starts with /user, then the Router will render the <UserLayout> element. But it will then continue checking further segments of the path: so if the path is /user/profile, then the Router will render the <UserLayout> (for the /user part), but then render the <UserProfile> (for the /profile part) inside of the <UserLayout> at an indicated location. Thus you could view the above Routes as defining the following paths:

  • /user/profile renders <UserLayout><UserProfile/></UserLayout>
  • /user/favorites renders <UserLayout><UserFavorites/></UserLayout>
  • /user renders <UserLayout></UserLayout> (no child)
  • /items renders <ItemList />

(You can think of each “nested child” step as a / in the path)

You specify where in the parent element the child element will render using an <Outlet> Component. This component will be replaced by the element of whichever child route matches the URL segment:

function UserLayout(props) {
    render (
        <div className="user-layout">
            <h1>User Page</h1>
            {/* will be replaced with <UserProfile>, <UserFavorites>, or null */}
            <Outlet />
        </div>
    )
}

Thus you can think of the <Outlet /> as the “place the child component will go”.

Nested routes are primarily used to create shared layouts, as in the example above. The <UserLayout> component can contain structure elements (like divs) that will be shared across routes that begin with the same segment, but not other routes.

Not all apps require nesting routes! If your entire app has a single layout, you don’t need to create a separate Component for that; you can just render it as part of your <App> and use the <Routes> to specify the dynamic content.

Similarly, remember that Routes are only for conditionally rendering a Component based on the URI. If you want to conditionally show content (e.g., depending on whether the user is logged in, or based on what item has been selected), you use conditional rendering based on the state—don’t use a Route!

If you wish to show a “default” child Component when there is no further segment, you can give the child Route the index prop:

<Routes>
    <Route path="user" element={<UserLayout />} >
        {/* show the UserHome at ``/user` */}
        <Route index element={<UserHome />} />
        <Route path="profile" element={<UserProfile />} />
        <Route path="favorites" element={<FavoriteItems />} />
    </Route>
</Routes>

Notice that the index prop (which takes no other values!) in effect “replaces” the path segment for that child.

It is often useful to specify the routes as a const variable (e.g., routes) that is an object containing paths and which component to render for that path. You can use the useRoutes() hook to then render this object instead of specifying a <Routes> element. This is only recommended for particularly large applications.

You can use the useMatch() hook to get access to the current path; this can be useful for specifying e.g., relative image paths.

URL Parameters

It is also possible to include variables in the matched route using what are called URL Parameters. As you may recall from reading a RESTful API, URI endpoints are often specified with “variables” written using :param syntax (a colon : followed by the parameter name). For example, the URI

https://api.github.com/users/:username

from the Github API refers to a particular user—you can replace :username with any value you want: https://api.github.com/users/joelwross refers to the joelwross user (so username = 'joelwross'), while https://api.github.com/users/mkfreeman refers to the mkfreeman user (so username = 'mkfreeman').

React Router supports a similar syntax when specifying Route paths. For example:

<Route path='posts/:postId' element={<BlogPost />} />

will match a path that starts with posts/ and is followed by any other path segment (e.g., post/hello, post/2022-10-31, etc). The :postId (because it starts with the leading :) will be treated as a parameter which will be assigned whatever value is part of the URI in that spot—so post/hello would have 'hello' as the postId, and post/2017-10-31 would have '2017-10-31' as the postId.

You can access the values assigned to the URL parameters by using the useParams hook provided by react-router. This hook returns an object whose keys are the parameter names and whose values are the param values:

import { useParams } from 'react-router-dom';

function BlogPost(props) {
    //access the URL params as an object
    //it's also common to use object destructuring here
    const urlParams = useParams();

    return (
        {/* postId was the URL parameter from the above example! */}
        <h1>You are looking at blog post {urlParams.postId}</h1>
    )
}

In the above BlogPost component, the urlParams value will be an object containing different values depending on the route:

  • If the element is rendered by <Route path="posts/:postId" element={<BlogPost />} />, then
    • visiting posts/hello will cause urlParams to be the object {postId: "hello"}
    • visiting posts/2022-10-31 will cause urlParams to be the object: {postId: "2022-10-13"}
  • If the element is rendered by <Route path="posts/:date/:title" element={<BlogPost />} />, then
    • visiting posts/2022-10-31/Hello will cause urlParams to be the object {date: "2022-10-31", title: "Hello"} (note the multiple values for the multiple parameters!)

If you want to work with query parameters (e.g., the ?key1=value1&key2=value2 part of the URL), you can use the useSearchParams() hook. This works similar to the useState() hook, except that the value will be stored in the URL query parameter rather than in the Component’s state. Note that query parameters should only be used for values such as search queries that don’t correspond to a consistent resource.

Linking

While specifying <Route> elements will allow you to show different “pages” at different URLs, in order for a Single Page Application to function you need to be able to navigate between routes without causing the page to reload. Thus you can’t just use normal <a> elements to link between “pages”—browsers interpret clicking on <a> elements as a command to send a new HTTP request, and you instead just want to change the URL and re-render the App.

Instead, React Router provides a <Link> element that you can use to create a hyperlink to another route within the application. This component takes a to prop that you use to specify the route that it links to:

<Link to="about">Click to visit the About Page</Link>
  • The component will render as an <a> element with a special onClick handler that keeps the browser from loading a new page. Thus you can specify any content that you would put in the <a> (such as the hyperlink text) as child content of the <Link>. Importantly: a <Link> is a replacement for an <a> element—do not try to put one inside of the other!

  • A to property that is relative path (so doesn’t have a starting /) will resolve relative to its parent route. Thus you can use .. to refer to the parent route as you would with any other relative path.

React Router also provides a <NavLink> Component. This works exactly like the <Link> component, except if the to route matches the current route, then the element will have the active CSS class added to it. This is used for example to have a navigation section “highlight” the link to the page you’re currently on, helping the user understand where they are on the page. It’s also possible to specify a callback function if you wish to use a custom name for the “active” class; see the documentation for an example (watch out of the ternary operator).

Redirecting

In addition to <Link> elements that allow the user to navigate by clicking an element, React Router also provides functionality to programmatically navigate through routes.

The primary tool for this is the useNavigate() hook. This hook, when called, will provide a function (conventionally named navigate) that can be called to redirect the App—to change the URL without reloading the page (and thus cause the Routes to re-render):

import { useNavigate } from 'react-router-dom';

//A simple form component
function Form(props){
    const navigate = useNavigate(); //access navigate function

    //event handler for the form
    const handleSubmit = function(event) {
        event.preventDefault();
        //do form submission work here

        navigate("/home") //navigate to the `/home` route
    }

    return (
      <form onSubmit={handleSubmit}> ... </form>
    )
}

The navigate() function takes an an argument the same stying you would use as the to prop for a <Link> element.

  • You should only use the navigate() function when non-navigation actions (such as form submissions) need to cause redirections. If the user is clicking on an element to navigate, just embed that element in a <Link>—that will keep your page accessible.

  • Additionally, only call navigate() from inside of an event handler; don’t use it in the body of a Compnent function—use a <Navigate> element.

Alternatively, you can cause the route to change by rendering a Navigate element. This element accepts a to prop just like a <Link>, and when rendered will redirect the user as if the link were clicked without the user doing anything.

To be clear: you need to render the <Navigate> element—to return it from a Component as the DOM to render. So instead of a component returning e.g., <div>, you would have it return a <Navigate>. Thus you would use this element with conditional rendering, using an if statement to determine whether you want to return/show regular DOM content or instead return a <Navigate> to redirect.

  • DO NOT render a <Navigate> as the child of displayed content (e.g., inside a <div>), as this can cause issues with the redirect taking multiple “DOM update cycles” to process, interfering with your application’s processing. Instead, determine whether you should redirect and if so return just the <Navigate> element (e.g., with a “break early” sentinel condition).

<Navigate> elements are particular useful when creating “protected routes”—routes that are only accessible under certain conditions (such as if the user is logged in). To do this, you have the Route’s element include an if statement to determine whether it should display content, or if it instead should show the <Navigate> and this redirect:

function ProtectedPage(props) {
  //...determine if user is logged in (eg., via AJAX)

  if(!userIsLoggedIn) { //if no user, send to sign in
    return <Navigate to="/signin">;
  }

  //otherwise show content
  return (
    <div>protected content!</div>
  )
}

A nice strategy is to combine the above logic with a nested route, allowing you to re-use authentication logic across your app:

function RequireAuth(props) {
  //...determine if user is logged in (eg., via AJAX)

  if(!userIsLoggedIn) { //if no user, send to sign in
    return <Navigate to="/signin">;
  }
  else { //otherwise, show the child route content
    return <Outlet />
  }
}

function App(props) {
  return (
    <Routes>
      {/* protected routes */}
      <Route element={<RequireAuth />}>
        <Route path="profile" element={<ProfilePage />} />
        <Route path="secret" element={<SecretPage />} />
      </Route>
      {/* public routes */}
      <Route path="signin" element={<SignInPage />} />
    </Routes>
  )
}

The Route rendering the <RequireAuth> has no defined path, defaulting to "" (thus not providing a segment to consider). So if its child route are matched, that component will render, and either use the <Navigate> to redirect to a public route, or to show the child route element in place of the <Outlet>. This structure lets you keep your protected routes organized, while keeping all of your “user is logged in” logic in a single location.

React Router and Hosting

React Router’s client-side routing introduce a few additional considerations when the you wish to deploy your app on a non-development server, such as Github Pages (e.g., what happens when you deploy a create-react-app project).

First, consider what happens when you type a route (e.g., https://domain.com/about to access the /about route) into the browser’s URL bar in order to navigate to it. This creates an HTTP Request for the resource at the URI with an /about path. When that request is received by the web server, that server will perform server-side routing and attempt to access the resource at that location (e.g., it will look for an /about/index.html page). But this isn’t what you want to happen—because there is no content at that resource (no /about/index.html), the server will return a 404 error.

Instead, you want the server to take the request for the /about resource and instead return your root /index.html page, but with the appropriate JavaScript code which will allow the client-side routing to change the browser’s URL bar and show the content at the /about route. In effect, you want the server to be able to return your root index.html page no matter what route is specified in the HTTP Request!

It is perfectly possible to have a web server do this (to not perform server-side routing and instead always return /index.html no matter what resource is requested); indeed, this is what the Create React App development server does. However GitHub Pages doesn’t have this functionality: if you send an HTTP request for a resource that doesn’t exist (e.g., /about), you will receive a 404 error. There are a few ways to work around this:

  1. You can use a <HashRouter> instead of a <BrowserRouter> The <HashRouter> uses the fragment identifier portion of the URI to record and track which route the user is viewing: the HTTP request is thus sent to https://domain.com/index.html#/about to get the /about route—and since index.html is the default resource, this can be abbreviated to https://domain.com/#/about, which is almost as good. In this way you are always requesting the appropriate resource (/index.html), but can still perform client-side routing. The trade-off is that your URLs will have extraneous # symbols in them (which also makes utilizing inner-page navigation with the fragment more difficult), and going to domain.com/about will still cause a 404 error. It’s recommend that you avoid this element if possible.

  2. Another approach is to replace your server’s 404 page with something that goes to your index.html (using server-side routing)—so instead of the user being shown the 404, they are shown your index.html which is about to do the client-side routing! spa-github-pages provides some boilerplate for doing this with GitHub Pages, but it is a “hacky” approach and so is also not recommended.

  3. The best and correct approach is to use a web hosting system that better supports the server-side routing needed for single-page applications. For example, Firebase Hosting allows you to specify a rewrite rule that will cause the server to return your index.html no matter which route the HTTP Request specifies. Create React App provides instructions on deploying to Firebase Hosting, including a simple wizard configuration that allows you to configure your site as a single-page application.

Resources