Skip to content
A photo of the underside of a concrete staircase which has lights installed, and a metal pipes running along the structure's supports.

Build a modal route with Reach Router

Using a dialog as a route is not as simple as it seems. Content behind overlay needs to remain rendered. With the elegance of React I'll document how I achieved a solution for this use case.

For my purposes I am using Reach Router and Reach UI's Dialog but any router should work, and any modal or alert component for a dialog.

Live example: CodeSandbox
Example code: GitHub

Pattern use cases

Instagram employs this pattern but does not remember the location when navigating backwards in the history.

Recently I've been working on a React project with long lists of entries. Each of these entries has additional details which I hid behind an expanding element. I then realized users might want to link directly to an expanded entry. I choose to use dialogs instead. They make navigating in a list fluid, as you can always see a glimpse of the list behind the dialog.

Twitter, Instagram, and Reddit all use this as one of their main navigation patterns. While they all behave differently, I decided the following is what I needed to implement the pattern successfully:

  • A link that opens a dialog
  • Update the browser history and location bar
  • Display the previous route behind the dialog
  • Remembering the route behind the dialog for consistency when navigating backwards (this is more of an edge case)

After some experimenting, I though the easiest way to determine if a route should be displayed as a dialog was to pass state through the Link component. Since we need to know which route to render behind the overlay, that is the state we will pass. This code will look much better once we get a Reach Router version with Hooks.

location has a few properties that are functions. Any state stored in History.state needs to be serializable, so we sanitize it using JSON methods. Alternatively we could also hand pick the properties @reach/router needs.

<Location>
  {({ location }) => (
    <Link
      to="/login"
      state={{
        oldLocation: JSON.parse(JSON.stringify(location)),
      }}
    >
      Login
    </Link>
  )}
</Location>

You can retrieve location from props.location if Link is being rendered in a direct child of <Router>. Make sure to only sanitize with JSON just once per page if multiple links open a dialog.

Displaying the previous route behind the dialog

Changing the location of the router causes it to render a new route. To keep our old route behind the dialog we will need to render two routers.

To simplify things we define our routes in a new component, making it easy to repeat.

function Routes(props) {
  return (
    <Router {...props}>
      <Home path="/" />
      <Login path="/login" />
      <Page path="/page/:number" />
    </Router>
  );
}

By default Reach Router uses the browser history to render it's location, but we can override that with a prop to render any route we want. This allows us to use our oldLocation which gets stored in location.state when a user clicks the link.

Overall the pattern is quite small to implement, we render a main router and conditionally render a dialog with the same router.

<Location>
  {({ location, navigate }) => {
    const { oldLocation } = location.state || {};
    return (
      <>
        <Routes location={oldLocation != null ? oldLocation : location} />
        <Dialog
          isOpen={oldLocation != null}
          onDismiss={() => {
            navigate(oldLocation.pathname);
          }}
        >
          <Routes location={location} />
        </Dialog>
      </>
    );
  }}
</Location>
  • If there is not oldLocation in the state
    • render the router, don't render the dialog (normal view)
  • If there is oldLocation in the state
    • render the router with oldLocation, and render the dialog with location

Conclusion

Don't forget to checkout the complete example or the code on GitHub to see how it all works together and some other rendering tips.