Jahed Ahmed

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Set current location and routes
export const initRouter = (state) => ({
type: "@router/INIT",
payload: { state },
});

// Change location and add it to browser history
export const push = (href) => ({
type: "@router/PUSH",
payload: { href },
});

// Change location and replace the current location in browser history
export const replace = (href) => ({
type: "@router/REPLACE",
payload: { href },
});

// Dispatched when browser history was successfully changed.
export const historyChanged = (href) => ({
type: "@router/HISTORY_CHANGED",
payload: { href },
});

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
2
3
4
5
6
7
8
9
10
type Routes = { [route: string]: Routes };

type Router = {
pathname: string;
query: Record<string, string>;
hash: string;
params: Record<string, string>;
method: string;
routes: 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
2
3
4
5
6
7
8
9
// Sync initial browser state to Redux
useEffect(() => {
dispatch(
initRouter({
href: window.location.href,
routes: { "/explore": { "/:gameId": {} } },
})
);
}, [dispatch, routes]);
1
2
3
4
5
6
7
8
// Sync History API changes to Redux
useEffect(() => {
const handler = (): void => {
dispatch(replace(window.location.href));
};
window.addEventListener("popstate", handler);
return () => window.removeEventListener("popstate", handler);
}, [dispatch]);
1
2
3
4
5
6
7
8
9
// Sync Redux changes to History API
useEffect(() => {
if (!state.method) {
return; // No transition
}
const href = toHref(state);
window.history[state.method]("", "", href);
dispatch(historyChanged(href));
}, [dispatch, state]);

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
2
3
4
<App>
<Router />
<!-- rest of the app -->
</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
2
3
4
5
6
7
8
9
<Route pattern="/explore">
<Explore />
<Route>
<Route pattern="/explore/:gameId">
<ExploreGame />
<Route>
<Route pattern="/" exact>
<Home />
<Route>

Routes can also be nested.

1
2
3
4
5
6
7
8
<Route pattern="/explore">
<div>
<h1>Explore</h1>
<Route pattern="/:gameId">
<Explore />
</Route>
</div>
<Route>

For matching patterns, we can use an existing library like url-pattern.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Route = ({ pattern = "", exact = false, children }) => {
const parent = useContext(RouteContext);
const pathname = useSelector((state) => state.router.pathname);
const fullPattern = `${parent}${pattern}`;
const urlPattern = useMemo(() => {
return new UrlPattern(`${fullPattern}${exact ? "" : "*"}`, {
segmentValueCharset: "a-zA-Z0-9-_~ %'()",
});
}, [exact, fullPattern]);
const matches = useMemo(
() => urlPattern.match(pathname),
[urlPattern, pathname]
);

if (!matches) {
return null;
}

return (
<RouteContext.Provider value={fullPattern}>
{children}
</RouteContext.Provider>
);
};

Switch

A Switch lets us render only the first Route that matches. It's like an if/else or switch/break statement.

1
2
3
4
5
6
7
8
9
10
11
<Switch>
<Route pattern="/explore">
<Explore />
<Route>
<Route pattern="/" exact>
<Home />
</Route>
<Route>
<NotFound />
</Route>
</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
2
3
4
5
6
7
8
9
10
11
12
13
14
const Link = ({ href, children }) => {
const onClick = useCallback(
(event) => {
event.preventDefault();
dispatch(push(href));
},
[dispatch, href]
);
return (
<a href={href} onClick={onClick}>
{children}
</a>
);
};
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.