Celebrate Bisexuality Day

In last week's post, we fetched an array of posts from JSONPlaceholder and rendered the data using React. Today, we'll use React Router to expand this example with a home page and individual pages for each post.

Client-side routing

React Router lets us handle routing on the client side:

In traditional websites, the browser requests a document from a web server, downloads and evaluates CSS and JavaScript assets, and renders the HTML sent from the server. When the user clicks a link, it starts the process all over again for a new page.

Client side routing allows your app to update the URL from a link click without making another request for another document from the server. Instead, your app can immediately render some new UI and make data requests with fetch to update the page with new information.

This enables faster user experiences because the browser doesn't need to request an entirely new document or re-evaluate CSS and JavaScript assets for the next page. It also enables more dynamic user experiences with things like animation.

This approach is necessary for highly interactive web apps, but I think it's overused. It results in a more fragile user experience, not only because JavaScript is the most expensive part of the front end, but also because it means breaking things that the browser does natively:

You need to…

  • Intercept clicks on links and suppress them,
  • Figure out which HTML to show based on the URL,
  • Update the URL in the address bar,
  • Handle forward/back button clicks,
  • Update the document title,
  • And shift focus back to the document.

This is all stuff that the browser just does out-of-the-box by default. This feels like a vicious circle.

We're literally breaking the features that the web gives you out of the box with JavaScript—to fix the performance issues we created with JavaScript—and then reimplementing these features with even more JavaScript… all in the name of performance (which again, we ruined in the first place with all of the JavaScript).

I think today's example would be better handled on the server side, but it's a nice and simple example for teaching purposes.

Creating a <Page> component

A couple of weeks ago, we learned about composition in React. We'll use this technique to create a reusable <Page> component:

import React, { Fragment, useEffect } from "react";

export default function Page(props) {
useEffect(() => {
document.title = props.title;
}, [props.title]);

return (
<Fragment>
<header>
<h1>{props.title}</h1>
</header>
<main>{props.children}</main>
</Fragment>
);
}

The <Page> component accepts a .title prop and renders its value within an <h1> element. It also uses its value inside the effect hook to update the document title. Any JSX nested within an instance of the <Page> component will be made available via the .children prop and rendered within a <main> element.

Creating an <ErrorPage> component

React Router is really flexible with its error handling. We can be as generic or as specific as we'd like. For our purposes, let's just create a simple <ErrorPage> component. We'll use another type of composition called specialization:

import React from "react";
import { useRouteError } from "react-router-dom";
import Page from "./page";

export default function ErrorPage() {
const error = useRouteError();

let message;

if (error.status === 404) {
message = <p>There's nothing here.</p>;
} else if (error.status === 500) {
message = <p>There was a problem fetching the data for this page.</p>;
} else {
message = <p>An unexpected error occurred.</p>;
}

return <Page title={error.statusText ?? "Error"}>{message}</Page>;
}

The <ErrorPage> component uses the route error hook provided by React Router. This gives us information about the error that occurred. If the HTTP response status code is either 404 (Not Found) or 500 (Internal Server Error), we're rendering a custom error message. Otherwise, we're rendering a generic one.

Creating a <Home> route

When the user visits the root of our app (/), we want to display a list of links to the individual post pages. Let's create a <Home> component for this:

import React from "react";
import { Link, useLoaderData } from "react-router-dom";
import Page from "../page";

export async function loader() {
const API = "https://jsonplaceholder.typicode.com/posts?userId=1";

try {
const response = await fetch(API);
if (!response.ok) throw new Error();

const data = await response.json();
return data;
} catch {
throw new Response(null, {
status: 500,
statusText: "Internal Server Error",
});
}
}

export default function Home() {
const posts = useLoaderData();

return (
<Page title="Posts">
{posts.length > 0 ? (
<ul>
{posts.map((post) => (
<li key={`post-${post.id}`}>
<Link to={`posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
) : (
<p>No posts to show.</p>
)}
</Page>
);
}

You'll notice that this file exports two things: a named export and a default export. The named export is a function called loader(). It can be named anything, but it's a function that provides data to the component before it's rendered. We're fetching a list of posts from JSONPlaceholder and throwing a response if there's an error.

The default export is the actual <Home> component. It uses the loader data hook provided by React Router. This gives us access to the data returned by the loader function. We're mapping each object in the array of posts to an <li> element. Inside each <li> element, we're rendering an instance of the <Link> component provided by React Router. These become real <a> elements, but they're React Router's way of knowing that these are internal links that use client-side routing. External links can just use regular <a> elements.

Creating a <Post> route

When the user navigates to an individual post page (e.g. /posts/1), we want to render the data for that post. Let's create a <Post> component for this. It will be very similar to the <Home> component, with a few differences:

import React from "react";
import { useLoaderData } from "react-router-dom";
import Page from "../page";

export async function loader({ params }) {
const API = `https://jsonplaceholder.typicode.com/posts/${params.postId}`;

try {
const response = await fetch(API);
if (!response.ok) throw new Error();

const data = await response.json();
return data;
} catch {
throw new Response(null, {
status: 500,
statusText: "Internal Server Error",
});
}
}

export default function Post() {
const post = useLoaderData();

return (
<Page title={post.title}>
{post.body.split("\n").map((paragraph, index) => (
<p key={`paragraph-${index + 1}`}>{paragraph}</p>
))}
</Page>
);
}

In React Router, every loader function receives an object with two properties: .request and .params. The value of the .params property is an object of parameters based on the dynamic segments of the route.

In our case, we're interested in the .postId parameter which refers to the number at the end of the route, e.g. /posts/1 or /posts/2. We're using the value of this parameter to fetch the data for an individual post. Inside the actual <Post> component, we're rendering the value of the post.body property.

Configuring the router

With all our routes in place, we just need to configure the router:

import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import Home, { loader as homeLoader } from "./routes/home";
import Post, { loader as postLoader } from "./routes/post";
import ErrorPage from "./error-page";

const container = document.getElementById("root");
const root = createRoot(container);

const router = createBrowserRouter([
{
path: "/",
element: <Home />,
errorElement: <ErrorPage />,
loader: homeLoader,
},
{
path: "posts/:postId",
element: <Post />,
errorElement: <ErrorPage />,
loader: postLoader,
},
]);

root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);

We're importing two things from React Router: the <RouterProvider> component and the createBrowserRouter() function. The <RouterProvider> component is the main entry point to our app while the createBrowserRouter() function is what we use to configure our routes. We're passing the return value to our <RouterProvider> instance via its .router prop. We're also importing our routes and their loader functions.

We're passing an array of objects to the createBrowserRouter() function. Each object represents a route. I prefer to use plain object literals, but you can alternatively use the createRoutesFromElements() function to configure your routes in JSX.

Both routes wire up their loader functions and both render the <ErrorPage> component if they encounter an error. The / route renders the <Home> component while the posts/:postId route renders the <Post> component. The :postId part of the path is the dynamic segment which is accessible via the params.postId property in the route's loader function.

Summary

Demo of client-side routing with React Router.

In today's post, we used React Router to enable client-side routing in our React app. This lets us use multiple pages even though we only have a single HTML file. This is known as a single-page app (SPA) and is useful for highly interactive web apps. While I think this approach is overused, I recommend Rich Harris' talk about transitional apps for a more optimistic view.

We've only scratched the surface of what React Router can do, so I recommend trying the official tutorial. React Router is actually part of Remix, a React framework I recommend for a more complete solution. Next.js is a popular alternative. Both tools support pre-rendering, meaning your app doesn't need to rely entirely on JavaScript in order to function. In other words, this is how we always used to build websites, and it results in a more stable experience.