Routing in React using Redux
Routing can be a pain in React when used with Redux. At the time when I went down this route for FrontierNav, the React/Redux/Router scene was in a bit of a mess. Maintainers abandoned their projects and every option seemed more complex than necessary which lead to frequent bugs.
React Router was and still is the go-to solution. But I enjoy using Redux for state management, just having everything going through Redux is convenient. I didn't want the overhead of using React Router and a Redux bridge for it either. So I went ahead and tried to see how difficult it'd be to make my own router, specific for my needs. Turns out, it's not that difficult.
It's been a few years now since I wrote the majority of this implementation. It's been stable, minimal and pretty flexible so I think it's worth sharing the general approach.
Note that the examples provided here will be a bit simplified. The main goal is to provide a rough example which we can extend for our own needs.
Concepts
Before getting started, we'll of course need to know a few things to understand what I'm talking about. React and Redux of course, and a few other things.
History API
To retrieve and modify location and location history in a web browser, we have to use the History API.
All of our routing essentially wraps this API to work seemlessly within the context of React and Redux's APIs.
Locations and Routes
Locations are specific paths as provided by window.location.href
, such as:
1 | /explore/minecraft |
A location can match a route. For example, the location above will match the following route:
1 | /explore/:gameId |
:gameId
is a path parameter. When combined with the above location, gameId
will be minecraft
.
State changes
Actions
Actions are used to change our router's state through a reducer.
1 | // Set current location and routes |
Other reducers can also use these actions to trigger state changes. For example, an overlay can close itself when the page underneath it changes.
Reducer
Like any side-effect-free reducer, our reducer's job is to take actions and update state. State looking like the following:
1 | type Routes = { [route: string]: Routes }; |
Given our actions provide href
strings, our reducer will parse them to pathname
, query
and hash
properties accordingly. To skip this, actions can be modified to provide these properties directly instead of a string.
method
is used to indicate what needs to be done to synchronise the current state to the History API. So it can be pushState
or replaceState
. This property is cleared when a historyChanged
action is triggered to mark state as synchronised and prevent infinite synchronisations.
routes
is used for discovering params
in paths. For example: an /explore/minecraft
location matches a /explore/:gameId
route, so params.gameId
will be minecraft
.
Router
A Router uses useEffect
hooks to initialise and tie together changes in Redux state and the History API.
History API is only used to synchronise browser state for forward and back buttons, and location bars. React components will use Redux state for their needs.
1 | // Sync initial browser state to Redux |
1 | // Sync History API changes to Redux |
1 | // Sync Redux changes to History API |
Note, the first empty string (""
) in the History API call is for history state. Our actions and reducers can be expanded to make use of this. History state is useful for storing any data that isn't available in the location but would be expected on page reloads. Things like scroll position, overlays and keyboard focus.
To trigger these useEffect
hooks, we need to mount the component, ideally at the top level of our app, before the components which need routing.
1 | <App> |
Router itself does not use Contexts. Instead, components will grab state from Redux using useSelector
. This separation between state (Redux) and controller (React) enables us to use a different Router implementation if we don't want to use the History API. With some tweaking, we can even have multiple routers, which is how FrontierNav's pop-out windows work.
Conditional rendering
For conditional rendering, our components will use Redux state to decide which page or component to render.
Route
A Route lets us render a component when the current location matches a given pattern.
1 | <Route pattern="/explore"> |
Routes can also be nested.
1 | <Route pattern="/explore"> |
For matching patterns, we can use an existing library like url-pattern.
1 | const Route = ({ pattern = "", exact = false, children }) => { |
Switch
A Switch lets us render only the first Route that matches. It's like an if/else or switch/break statement.
1 | <Switch> |
This works because Switch doesn't render its children like a typical component might. Instead, it iterates through each element using Children.forEach
and matches their pattern; similar to a Route. It will then only render the first Route that matches.
Navigating locations
We can dispatch push
and replace
actions to navigate from any function. This lets us create useful components to better integrate with React.
Link
Links let us render <a>
tags and intercept its traditional behaviours. So instead of loading an entirely new web page, we stay within our application and dispatch a push
action instead.
1 | const Link = ({ href, children }) => { |
1 | <Link href="/explore">Explore</Link> |
Conclusion
That's more or less all the bits we need. We now have a complete routing and navigation solution for our web app, which can be expanded to support more features and complex use cases like FrontierNav does.
Thanks for reading.