Smarter, Dumb Breadcrumb

Published: 10 August 2019

Breadcrumbs can be a useful tool to help users find their place within your website or application. I'm going to build one using the compound component pattern and demonstrate that with the help of React Context and Portals we can utilise the component tree to generate a breadcrumb trail that doesn't know your hierarchy or your location within it.

There will be some advanced techniques here but I won't be going into detail regarding how Hooks, Context or Portals work. If you feel you may be lacking knowledge in any of these areas I would suggest reading through those links before following along.

I'll start by building the presentational parts of the component with styled-components. At the time of writing, it's my preferred styling choice but isn't required for this to work.

import React from 'react';
import { BrowserRouter, NavLink } from 'react-router-dom';
import styled from 'styled-components';

const Breadcrumb = ({ children }) => (
  <nav aria-label="Breadcrumb">
    <Items>{children}</Items>
  </nav>
);

const Items = styled.ol`
  margin: 0;
  padding-left: 0;
  list-style: none;
`;

const BreadcrumbItem = ({ children, to, ...props }) => (
  <Item {...props}>
    <ItemLink to={to}>{children}</ItemLink>
  </Item>
);

const Item = styled.li`
  display: inline;

  & + &::before {
    content: '';
    display: inline-block;
    transform: rotate(15deg);
    border-right: 1px solid currentColor;
    height: 1em;
    margin: 0 8px -0.2em;
  }
`;

const ItemLink = styled(NavLink).attrs({ exact: true })`
  color: #36d;
  text-decoration: none;
  border-bottom: 1px solid transparent;

  &:hover {
    border-color: currentColor;
  }

  &.active {
    border: none;
    color: inherit;
  }
`;

const App = () => (
  <Breadcrumb>
    <BreadcrumbItem to="/one">One</BreadcrumbItem>
    <BreadcrumbItem to="/two">Two</BreadcrumbItem>
  </Breadcrumb>
);

I could stop here. This is a Breadcrumb component and it is dumb, however, it requires each consumer implementation to manually hardcode the breadcrumb items that precede the current page or create a smart container that maintains a record of your hierarchy and your position within it. There is a simpler way.

Portals

React Portals provide the ability to render a component anywhere in the DOM so we can render a component outside its current branch in the component tree. This will be useful but it also does something even more beneficial. When passing a component through a Portal it will not replace the children in the node you provide, it will append to it. Why is this relevant?

A breadcrumb trail consists of a list of links to the parent pages of the current page in hierarchical order.

https://www.w3.org/TR/wai-aria-practices-1.1/#breadcrumb

When using a router package that maintains hierarchy such as React Router, we end up with a hierarchical execution order for our pages. So, if the Breadcrumb became a portal node in our application header, the BreadcrumbItems can be consumed outside the header branch on a page by page basis and get appended to our header in hierarchical order.

The following iterates on our existing component to achieve this:

This is cool. We can see our renamed BreadcrumbItems (now Breadcrumb) being rendered in the Header component even though they're consumed outside of it. But what's going on here?

const Breadcrumb = ({ children, to, ...props }) => {
  const [portalNode, setPortalNode] = useState();

  useEffect(() => {
    setPortalNode(document.getElementById('breadcrumb'));
  }, []);

  return portalNode
    ? ReactDOM.createPortal(
        <Item {...props}>
          <ItemLink to={to}>{children}</ItemLink>
        </Item>,
        portalNode,
      )
    : null;
};

The useEffect gets the portal node when the component has mounted and stores it in local state. This will cause the component to re-render and portal itself into the BreadcrumbPortal because it contains the element with the "breadcrumb" ID.

Unfortunately, we can only have one instance of this component on a page at the moment as we would end up with conflicting "breadcrumb" IDs on the page. There's also the possibility that our consumer has already given something the same ID. The current solution is not ideal and not very dumb but we can solve this with a reference to the node and Context.

Context

Let's make some changes to the portal component so we do not need to use an id attribute.

const BreadcrumbPortal = () => {
  const [, setPortalNode] = useBreadcrumbContext();
  return (
    <nav aria-label="Breadcrumb">
      <Items ref={setPortalNode} />
    </nav>
  );
};

I've removed the id attribute from the Items component and used the ref prop instead to get its DOM node but we now need a way to communicate this value to the breadcrumb item component even though it will not be consumed as a child of this component. This is where the useBreadcrumbContext hook comes in.

The hook will allow our breadcrumb components to consume some shared state provided by a BreadcrumbProvider component without having a parent/child relationship between themselves.

import React, { useContext } from 'react';

const Context = React.createContext();

const useBreadcrumbContext = () => {
  const context = useContext(Context);

  if (!context) {
    throw new Error('Missing BreadcrumbProvider.');
  }

  return context;
};

The hook wraps the existing useContext hook and will error if the BreadcrumbProvider we're about to create is missing, or otherwise return the provided context value.

const BreadcrumbProvider = ({ children }) => {
  const portalNodeState = useState();

  return (
    <Context.Provider value={portalNodeState}>{children}</Context.Provider>
  );
};

const rootElement = document.getElementById('root');

ReactDOM.render(
  <BrowserRouter>
    <BreadcrumbProvider>
      <App />
    </BreadcrumbProvider>
  </BrowserRouter>,
  rootElement,
);

The BreadcrumbProvider initially provides some empty state as the context value and wraps our application so that any breadcrumb components within the application can share this state.

At the moment, this set up assumes there will only be one breadcrumb in our application but if we wanted more we could reuse the BreadcrumbProvider at a lower level. Consumers will consume the context of their nearest provider so this gives us the flexibility to have as many breadcrumbs as we like without any conflicting IDs.

When setPortalNode is called within BreadcrumbPortal above, it will update the portalNode state in the BreadcrumbProvider which will update the Context value and trigger a re-render of any children that consume it. Therefore, any Breadcrumb components that exist as a (deeply nested or immediate) child of BreadcrumbProvider will be able to reference this DOM node and portal into it.

const Breadcrumb = ({ children, to, ...props }) => {
  const [portalNode] = useBreadcrumbContext();

  return portalNode
    ? ReactDOM.createPortal(
        <Item {...props}>
          <ItemLink to={to}>{children}</ItemLink>
        </Item>,
        portalNode,
      )
    : null;
};

When paired with React Router, this gives us everything we need to build a breadcrumb trail that doesn't know your application hierarchy.

Conclusion

We can do some pretty powerful things when combining hooks, portals and the compound component pattern. I highly recommend experimenting some more with these techniques, it's good fun and can simplify the consumer logic in your application.

If there's anything here that I haven't explained very well (likely 😛) or you'd like to discuss further then feel free to drop me a tweet at @jjenzz.

© 2024