Justina's thoughts and learnings

Breadcrumb Navigation Using React Router v6

A couple weeks ago I wanted to add what I recently discovered are called breadcrumbs to my frontend. I described it as "a representation of the pathname in my header". I didn't know navigation breadcrumbs was a thing so my first instinct was very simple:

  1. Get the path of the current URL (useLocation from React Router, ex: /cooked/recipes/spaghetti)
  2. Tokenize the pathname, display each token string with a separator in the header, and add a link for each token. For example, on the page for a spaghetti recipe, the header would display "cooked / recipes / spaghetti". Clicking "spaghetti" would link to https://baseURL.com/recipes/spaghetti, "recipes" would link to https://baseURL.com/recipes, and "cooked" would link to https://baseURL.com

Looking back, I think this could have also worked. I would have had to get the current URL every time I navigated to a new page and do some small computation to render the proper header, but still, it would have worked.

Later, when I learned the correct terminology and after a simple google search, I found that React Router described a breadcrumbs use case for their useMatches hook, so I attempted to try this.

Unfortunately, the React Router documentation for breadcrumbs isn't that detailed so I had to do a lot of googling to put the pieces together, but after some time, I realized that my current method of routing using BrowserRouter wasn't going to work. I wanted to take advantage of React Router's data API in order to easily render the recipe name in my crumb (instead of id), so before getting to my Breadcrumbs component, I needed to refactor my code to use createBrowserRouter (which is the recommended router for all React Router web projects)

createBrowserRouter

Below is how my routing was set up using BrowserRouter:

// App.tsx

...

function App() {
  return (
    <BrowserRouter>
      <div className="flex flex-col m-auto min-h-[100vh]">
        <Header />
        <div className="flex-grow flex justify-center">
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/recipes" element={<Recipes />} />
            <Route path="/recipes/:_id" element={<RecipeDetails />} />
            <Route path="/recipes/new" element={<CreateRecipe />} />
          </Routes>
        </div>
        <Footer />
      </div>
    </BrowserRouter>
  );
}

Initially, in my refactor, I followed the React Router documentation where createBrowserRouter takes in an array of Route objects with nested routes on the children property. This createBrowserRouter object is passed to the <RouterProvider> component, which is returned and rendered by the app and enables the data APIs.

//App.tsx

...

const router = createBrowserRouter([
  {
    element: <Layout />,
    children: [
      {
        path: "/",
        handle: { crumb: () => <Link to={`/`}>cooked&nbsp;</Link> },
        children: [
          { index: true, element: <Home /> },
          {
            path: "/recipes",
            handle: {
              crumb: () => <Link to="/recipes">/&nbsp;recipes&nbsp;</Link>,
            },
            children: [
              {
                index: true,
                loader: async () => {
                  const recipes = await recipeService.getAll();
                  return recipes.reverse();
                },
                element: <Recipes />,
              },
              {
                path: "/recipes/:_id",
                element: <RecipeDetails />,
                loader: async ({ params }) => {
                  return await recipeService.get(params._id!);
                },
                handle: {
                  crumb: (data: RecipeType) => (
                    <Link to={`/recipes/${data._id}`} reloadDocument>
                      /&nbsp;
                      {data.name}
                    </Link>
                  ),
                },
              },
              {
                path: "/recipes/new",
                element: <CreateRecipe />,
                handle: {
                  crumb: () => <Link to="/recipes/new">/&nbsp;new</Link>,
                },
              },
            ],
          },
        ],
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

As you can see, createBrowserRouter looks very different from the BrowserRouter usage that I initially had, which uses JSX. It turns out that createBrowserRouter router can also be written with JSX!

...

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route element={<Layout />}>
      <Route
        path="/"
        handle={{ crumb: () => <Link to={`/`}>cooked&nbsp;</Link> }}
      >
        <Route index={true} element={<Home />} />
        <Route
          path="/recipes"
          handle={{
            crumb: () => <Link to="/recipes">/&nbsp;recipes&nbsp;</Link>,
          }}
        >
          <Route
            index={true}
            element={<Recipes />}
            loader={async () => {
              const recipes = await recipeService.getAll();
              return recipes.reverse();
            }}
          />
          <Route
            path="/recipes/:id"
            element={<RecipeDetails />}
            loader={({ params }) => recipeService.get(params.id!)}
            handle={{
              crumb: (data: RecipeType) => (
                <Link to={`/recipes/${data._id}`} reloadDocument>
                  /&nbsp;
                  {data.name}
                </Link>
              ),
            }}
          />
          <Route
            path="/recipes/new"
            element={<CreateRecipe />}
            handle={{
              crumb: () => <Link to="/recipes/new">/&nbsp;new</Link>,
            }}
          />
        </Route>
      </Route>
    </Route>
  )
);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

The <Route handle> property is particularly important for our implementation of breadcrumbs using the react router hook useMatches. In the handle, I define a function to "crumb" that will be rendered in the breadcrumbs for that route. For example, we define a Route for the path /recipes/:id where :id is the id for a specific recipe. When the user navigates to that path, the <Route loader> fetches the data for that recipe from our backend and the data can be accessed by the crumb handle at data in order to render the recipe name (instead of the id). Code is as follows:

...
handle={{  crumb: (data: RecipeType) => (
                <Link to={`/recipes/${data._id}`} reloadDocument>
                  /&nbsp;
                  {data.name}
                </Link>
              ),
            }}
            ...

There is a Link element wrapping the name so that when the name in the breadcrumb is clicked, we will navigate to the designated path at to={/recipes/${data._id}}

Up to now I reviewed some of the logic and implementation of the breadcrumbs but how is this displayed/rendered to the user? This is where the Breadcrumbs component comes in.

Breadcrumbs

import { UIMatch, useMatches, useSearchParams } from "react-router-dom";
type HandleType = {
  crumb: (data?: RecipeType) => React.ReactNode;
};

function Breadcrumbs() {
  const [searchParams] = useSearchParams();
  const edit = searchParams.get("edit");

  const matches = useMatches() as UIMatch<RecipeType, HandleType>[];
  const crumbs = matches
    // first get rid of any matches that don't have handle and crumb
    .filter((match) => match.handle?.crumb)
    // now map them into an array of elements, passing the loader
    // data to each one
    .map((match) => match.handle.crumb(match.data));

  return (
    <ol className="flex text-lg">
      {crumbs.map((crumb, index) => (
        <li key={index}>{crumb}</li>
      ))}
      {edit === "true" && <li>&nbsp;/&nbsp;edit</li>}
    </ol>
  );
}

export default Breadcrumbs;

In order to make the Breadcrumbs component, we use the React Router useMatches hook, which returns the current route matches on the page. Pairing <Route handle> with useMatches is very powerful because we can access useMatches from anywhere in our app. Simply put, we can access and render the various crumb handles that make up our current route.

We grab our current route matches. For example, for the path /recipes/new, we can match the routes /recipes and /recipes/new and access the associated crumb handles.

...

const matches = useMatches() as UIMatch<RecipeType, HandleType>[];

...

The array of matches is filtered for those that have a crumb and then mapped to an array of React elements, providing data from the route loader, if it exists.

...

const crumbs = matches
    // first get rid of any matches that don't have handle and crumb
    .filter((match) => match.handle?.crumb)
    // now map them into an array of elements, passing the loader
    // data to each one
    .map((match) => match.handle.crumb(match.data));

...
    

Finally, the array items are each mapped to a list <li> element and nested in an ordered list <ol> element which is returned and rendered in the UI.

...

return (
    <ol className="flex text-lg">
      {crumbs.map((crumb, index) => (
        <li key={index}>{crumb}</li>
      ))}
      {edit === "true" && <li>&nbsp;/&nbsp;edit</li>}
    </ol>
  );
...

The product

Check out the final breadcrumbs feature below:

breadcrumbs.gif

Lessons Learned

There's a few things I learned while implementing this feature.

  1. It's always a good idea to talk to people about my ideas because it is likely a learning opportunity. As I wrote at the start of this post, I didn't know "breadcrumbs" were a thing. I had no idea that what I wanted to implement had an actual name.
  2. Go to the documentation first for problem-solving. Frameworks/libraries are changing all the time so it's good to start at the source so that you're implementing with the recommended methods and approaches for the most recent version.
  3. Sometimes documentation can be pretty confusing and lack detailso supplement it with googling! Don't be afraid to google. It's actually a very useful skill and can help fill gaps that are possibly assumed in the source documentation.
  4. Recognize that there's no "right" way of doing something. There were various ways that people on the internet implemented their breadcrumbs. Sure, maybe some were more concise or more efficient than other implementations but sometimes those things don't end up mattering too much if it works and the code is readable. Ultimately, it's up to us to weigh the pros, cons, goals of the app :)