ReactTDD.com

MemoryRouter vs HistoryRouter

React Router’s own documentation recommends that you use the MemoryRouter component in your test suites. But it isn’t sufficient for some testing scenarioes.

Warning: the examples in the posts are based on versions of React Router before 6.4. The React Router library changes at an extremely fast pace and some of the concepts here are out of date already.

The MemoryRouter component mimics how a real browser would handle page navigation, by storing your page history in an array that can be pushed into and popped out of.

The problem is, you don’t have access to that array. You can’t control navigation programatically, even though this is something that you may occasionally want to do in your app.

In the book Mastering React Test-Driven Development, there’s an example of this where a Redux saga causes page navigation after an HTTP request completes successfully.

(Note: I’m not saying this is a reasonable thing to do. Just that it’s an example of how the problem can appear, and what you can do about it. At the end of this post there’s an alternative suggestion for how to approach this problem.)

React Router uses the npm history package to manage the browser state. We need to tap into the history object it creates for its Router, and act as a second client into the same shared object.

In the book, the file src/history.js contains this:

import { createBrowserHistory } from "history";

export const appHistory = createBrowserHistory();

If you’ve installed the react-router-dom library then you already have access to this history package, since it’s a dependency. (And it’s important you use the version included with React Router rather than your own direct history dependency.)

Now you have an appHistory object that can utilised in your code. Here’s a snippet of src/sagas/customer.js (the full source is here):

import { put, call } from "redux-saga/effects";
import { appHistory } from "../history";

export function* addCustomer({ customer }) {
  const result = yield call(...);
  if (result.ok) {
    const customerWithId = yield call(...);
    ...
    appHistory.push(
      `/addAppointment?customer=${customerWithId.id}`
    );
  }
}

Now this only makes sense if you can also give the Router the same object to share. That’s what HistoryRouter is for, which React Router exports as unsafe_HistoryRouter:

import React from "react";
import ReactDOM from "react-dom/client";
import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom";
import { appHistory } from "./history";
import { App } from "./App";

ReactDOM.createRoot(document.getElementById("root")).render(
  <HistoryRouter history={appHistory}>
    <App />
  </HistoryRouter>
);

To be clear, you are discouraged from using this unless you have to. The React Router documentation doesn’t even mention this component.

So how might you do this with a simple MemoryRouter? Well, your non-React code needs to get back into React-land somehow so it can make use of the useNavigate hook from inside a component.

With the Redux example above that could mean dispatching another action to update state that could then be tied to a useEffect hook which would then navigate as you wish.

export function* addCustomer({ customer }) {
  const result = yield call(...);
  if (result.ok) {
    const customerWithId = yield call(...);
    ...
    // the call to appHistory.push is replaced
    // with another reducer action
    yield put({
      type: "ADD_CUSTOMER_SUCCESSFUL",
      customer: customerWithId,
    });
  }
}

Then you’ll need to make sure your reducer can handle that (I’m omitting a whole bunch of code here):

...
case "ADD_CUSTOMER_SUCCESSFUL":
  return {
    ...state,
    status: "SUCCESSFUL",
    customer: action.customer,
  };
...

In your component, if you’re pulling in that state from the store you can then use it in a useEffect hook.

import { useNavigate } from "react-router-dom";

const navigate = useNavigate();

useEffect(() => {
  if (reducerStatus === "SUCCESSFUL") {
    navigate(`/addAppointment?customer=${customerWithId.id}`)
  }
}, [reducerStatus, customerWithId]);

So that’s about all the plumbing you’d need–not a huge amount, but not insignificant either.

As with many things, this is horses for courses really… use whichever alternative suits you best. I often find myself wanting to keep my Redux code is as simple as possible.

— Written by Daniel Irvine on September 23, 2022.