Routing with Redux
If you've ever tried to pair Redux and a routing library like React Router, you'll know that there's a bit of inherent conflict there. Both React Router and Redux kind of want to "own" state in your app. But, a big part of the state in a given application is its current URL! Personally, I've never quite been taken by React Router or the patterns it promotes, I've always wanted all state that isn't inherently "local" to a component to live in Redux. The URL was no exception.
Let's break down routing a bit and see what it needs to do for us. The basic idea of client-side routing is this:
- Show the right components for the right URL.
- Enable "internal" navigation not to cause full-page refreshes (you know, that single-page-app bit).
- Properly handling things like the user clicking the "back" button.
But do we really need a big fancy routing library to handle this for us?
What if instead we just let Redux store the current URL and then derive everything else such as what components to show, and what data to fetch off of that URL state? Perhaps we could use selectors to do a lot of this work? Maybe we can select what components to show as well?
To effectively let Redux "own" URL state we need a few things:
- If the browser is opened to a certain URL, we need that as our starting state in Redux. Similarly, if the user clicks on "back" or "forward," we need our application state in Redux to be updated appropriately.
- If a URL change happens because our app changed it the browser's URL bar needs to be updated.
So really, it's a two-way binding between the browser's URL bar and our Redux store, right? Well as it turns out, that part is pretty straightforward, we can do it in just a couple of lines of code:
// Update Redux if we navigated via browser's back/forward
// most browsers restore scroll position automatically
// as long as we make content scrolling happen on document.body
window.addEventListener('popstate', () => {
// here `doUpdateUrl` is an action creator that
// takes the new url and stores it in Redux.
store.dispatch(doUpdateUrl(window.location.pathname))
})
// The other part of the two-way binding is updating the displayed
// URL in the browser if we change it inside our app state in Redux.
// We can simply subscribe to Redux and update it if it's different.
store.subscribe(() => {
const { pathname } = store.getState().routing
if (location.pathname !== pathname) {
window.history.pushState(null, '', pathname)
// Force scroll to top this is what browsers normally do when
// navigating by clicking a link.
// Without this, scroll stays wherever it was which can be quite odd.
document.body.scrollTop = 0
}
})
We also need to make sure that the reducer we write that keeps track of URLs will start out with the correct data. We could do this a couple of different ways, but it seems most logical to read it from location
it as part of initialState
in our reducer:
// starting state for our URL pathname reducer
const initialState = {
pathname: typeof location !== 'undefined' ? location.pathname : '/'
}
// the reducer itself
const urlReducer = (state = initialState, action) => {
if (action.type === 'UPDATE_URL') {
return { pathname: action.payload }
}
return state
}
// an action creator for updating it
const doUpdateUrl = pathname => ({ type: 'UPDATE_URL', payload: pathname })
With that, we can change the URL by clicking back/forward in the browser or by dispatching doUpdateUrl('/new-pathname')
on our store. Pretty simple, right? Astute observers will also note that there are scenarios where we want to replaceState
instead of updateState
. Again, this is easily handled by adding a second argument to our action creator for replace
.
Additionally, the neat thing about this approach is that it plays nicely with Redux time-travel debugging tools. Because it's bound to the browser, any changes to the URL via Redux will be reflected in the browser. In some ways, it's more like rendering the URL to the browser's URL bar. But this means since Redux is the authority on URL state we can still walk state changes back-and-forth in the timeline and have the browser show the right URL the whole time.
Extracting route parameters
One challenge with this type of routing approach is how to extract useful, relevant parameters from the URL. Often routing systems will allow us to define routes with patterns like /items/:id
and then extract the id
parameter from such a URL. Again, it's not too difficult to use selectors to define whatever routing mechanism we want out of this.
A few little tools and libraries on npm that can help with this include:
http-hash
(787 Bytes)ruta3
(742 Bytes)feather-route-matcher
(482 Bytes)
They're all based on the same general idea of defining and matching route strings against URLs and would be easy to use for writing a selector that returns the matching route. Personally, I use feather-route-matcher
because I wrote it, but ruta3
and http-hash
both have significantly higher download counts.
What about restoring scroll position?
Unfortunately, browsers don't all do automatic scroll position restoration the same way. Amazingly, the above code is all that's needed for Safari, Chrome, and Edge to restore scroll positions. But, for it to work on FireFox and IE 11, we have to do a bit more. FireFox actually implements it pretty close to how the spec says it should work. However, the other browsers seem to purposely ignore the spec in this regard and argue the spec should be changed to better support scroll position restoration in single page apps.
The point is we can address these differences with a few small helper functions that persist and restore scroll position properly. The included minimal-routing example demonstrates this thoroughly. It essentially amounts to saving scroll position as part of the state
argument you can set on the window.history
object. I won't go into the details here; please review it there if you're interested. The way it's implemented in the example works on all the modern browsers + IE11.
The example is available here: https://reduxbook.com/minimal-routing
What if you want to do transitions?
Transitions are a bit trickier in any "point-in-time" rendering approach like React. But you can certainly do it. I'll describe it at a high level here for the curious:
- You can create a transition component whose job it is to render "pages" within your app.
- This component can be
connect()
'ed to the current pathname. - As routes change, you can transition out the previous component and transition in the next component by temporarily storing them in the transition component's local state.
- With this approach, you can also compare the current pathname and the next pathname and imply from the URL structure whether or not you should transitioning "deeper" or back up in the information hierarchy.
There are a lot of different approaches to this type of thing depending on how you want your app to work.
I bring this up primarily to say that it's still doable with this type of routing approach. Personally, I tend to avoid page transitions and instead focus on showing the next thing as fast as possible.
Waxing eloquent (or not)
Many times, we over-engineer by creating generic solutions that include solutions for problems we don't actually have. It's tempting to use large routing libraries just because of their pretty APIs when all we really need to do is match a couple of strings. The beautiful thing about starting out with the super simple approach of storing a URL in a reducer is we can add more "smarts" when needed. We can use selectors to isolate and extract whatever relevant pieces of information we need from that URL. This flexibility lets us keep things as simple as they can be, based on our requirements instead of always using a bulldozer even if all we need to do is weed a flower bed.
There's always a balance here, of course. I've personally settled on using the "bundles" approach that I will introduce in the last chapter. The redux-bundler library that I use includes a lightweight solution to both URL handling and routing. Regardless, I wanted to shine a light on routing because I find that many developers don't realize how simple it really can be. They often assume they need a 12kb routing library just to show the right component for the right URL which simply isn't true.
Chapter recap
- Clientside routing is a form of application state.
- Most routing libraries want to "own" URL state, which puts them inherently at odds with Redux.
- We actually need a two-way binding between the browser's URL and our application state.
- We can do this by listening for
popstate
in the browser and using the new URL to update our Redux store and by subscribing to the store and updating the browser if it doesn't match what our Redux state says it should be. - A runnable example showing minimal routing can be found here: https://reduxbook.com/minimal-routing
- We can extend this approach to extract route parameters by using selectors and a small route matching library.