Building a Router in React
Recently, I was inspired by a tweet by Joel Califa to attempt to build a simple router in React. The goal was to end up with three things:
- A way to render different “page” components depending on the location’s path
- A way to link between those pages without having to pass state around
- Use no dependencies (other than React, of course)
My first instinct was that I could just use existing browser history event APIs.
As long as I have a
PageLink component that calls
when clicked, there must be some event that a router context can listen in on
when that happens. Unfortunately, this isn’t the case! The browser provides a
popstate event, but that’s only called as a result of user action (such as
clicking the back button) or some other history APIs. We will end up using
popstate, eventually, though, so stay tuned,
First, let’s set up the basic application skeleton. We’ll have an
component that renders a different page, depending on the current route.
Sketched out roughly, it looks something like this:
Pretty simple! We get the route (somehow) and render the appropriate page based on that route.
We also need some way of linking between these pages. For that, we’ll create a
This component just renders its children inside of a link. When clicked, we’ll
have to come up with some way for it to set the route to the
Note that we want to avoid passing state around, so we’re going to rule out
props.setRoute. In addition, we want to be able to put these
page components in separate files, so relying on
setRoute being in
scope and using closures is out of the picture, as well.
Thankfully, React has a very useful tool for when we want to pass data through the component tree without having to pass props down manually at every level. It’s called Context, and its documentation states that it “provides a way to pass data through the component tree without having to pass props down manually at every level”!
In order to make use of React Context, we’re going to build a component called a Provider. Essentially, the Provider component is what will allow Consumer components, which we’ll build later, to consume (and update) the routing state! First we’ll create our Context object and our Provider component:
RouterProvider component uses
RouterContext.Provider component in the
context object we created. It encapsulates one piece of state: the
itself, which we’ll set up soon. In addition to the
route property, the
component also provides a
setRoute function. Note, however, that we’re not
just blindly passing the
setRoute function created by
useState(location.pathname). This would work for state-management purposes,
but we also want to ensure that setting the route also updates the current URL!
In order to do that, the
setRoute function in the provider calls the browser’s
history API and “pushes” the new route into the history stack via
history.pushState(null, '', path). After that, then it updates its route
state by calling the original
setRoute function provided by
Wiring it Up
Now that we have the provider set up, we’ll use it (and the consumer) in our application component to get the current route for rendering the proper page component:
Now, anything inside of the component tree from that point down can use
RouterContext.Consumer to consume the
route and (if necessary)
PageLink component can also make use of the consumer in order to read
and set the current route state! Let’s also say, maybe, that we want
to only wrap its children in an anchor tag if it’s not already the active route.
PageLink component just renders its children with no anchor if the
current route state matches the
path property. If it doesn’t, then it renders
an anchor tag. When that tag is clicked, instead of navigating to the URL in the
href attribute of the tag, the component instead calls
setRoute from our
Handling User Navigation
Our router works pretty well now! Users can click around and navigate the app, and the URL changes along with the page contents, all without any page reloads. In testing this out though, I noticed there’s at least one major caveat. When the user presses the back/forward buttons in the URL, the browser location changes, but the app doesn’t render anything different to reflect that change.
The reason this is happening is that although we’re tracking history state
changes in this document when they’re initiated by our code calling
we’re not updating state when the user does something to navigate back and
forth through that history stack. Thankfully, there’s an API for doing that, and
this is where
popstate comes back in. When the user presses the back/forward
buttons in their browser, the window object receives a
notifying us that the page’s path has changed.
We’ll update our
RouterProvider component to make sure we’re reacting to those
React.useEffect to set up an event listener on the
window object. When the window receives a
popstate event, we’ll call
setRoute and pass it the new location that resulted from the navigation event.
Likewise, when the
RouterProvider is torn down, we’ll remove that listener so
that we don’t accidentally try to set state in an unmounted component.
All in all, I think that this was a successful and pretty fun experiment. I’m sure there are some edge cases that I haven’t covered, but I’m certainly impressed by how far one can get just by using the fundamental tools provided by React itself.
Of course, there’s a lot more that a router sometimes needs to do. For example, this completely ignores the common use case of using path parameters that would likely get passed to our page components. So I don’t mean to trivialize any real routing libraries, by any means!
If you want to play around with the code, fork
can even deploy it to Zeit with