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 history.pushState(path)
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, popstate
fans!
The Setup
First, let’s set up the basic application skeleton. We’ll have an App
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
PageLink
component:
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 path
property.
Note that we want to avoid passing state around, so we’re going to rule out
anything like props.setRoute
. In addition, we want to be able to put these
page components in separate files, so relying on route
and setRoute
being in
scope and using closures is out of the picture, as well.
Context
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:
This RouterProvider
component uses RouterContext.Provider
component in the
context object we created. It encapsulates one piece of state: the route
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 useState
.
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) setRoute
values.
Now, our 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 PageLink
to only wrap its children in an anchor tag if it’s not already the active route.
Now, the 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
router context.
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 setRoute
,
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 popstate
event
notifying us that the page’s path has changed.
We’ll update our RouterProvider
component to make sure we’re reacting to those
events:
Now, RouterProvider
uses 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.
Conclusion
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
jclem/react-simple-router. You
can even deploy it to Zeit with yarn deploy
.