How to convert withRouter to a React hook.

charlesstover

Charles Stover

Posted on June 5, 2019

How to convert withRouter to a React hook.

One of the greatest benefits of React Hooks in the new v16.7 is the removal of the reliance on higher-order components. While in the process of migrating my class states to functional hooks, I was giddy at the opportunity to port my with-router higher-order component to a hook as well. I have been using this custom implementation to re-render my components on route change. react-router's built-in withRouter HOC does not re-render components on route change, and it is a difficult issue to work around.

This article should serve as a tutorial for implementing a React hook with pub-sub functionality. Pub-sub, short for “publish-subscribe,” is a programming methodology wherein a set of subscription processes become notified when an update is published. In short, when the location changes (the published event), I want to re-render my component (the listener subscription).

The final product requires React Router v4.4 or greater, as previous versions of React Router did not expose the Router Context, which we are using to listen to location changes. If the latest version of React Router is not accessible to you, you can listen to the window history state as an alternative. I opted to use the same “single source of truth” that react-router uses, insuring that my states are always in sync.

You may use this package yourself via use-react-router on NPM or contribute to, fork, or otherwise spy on the open-source GitHub repository.

The User Experience 🙃

I start every project by asking the question, “What do I want to do?” This does not mean, “How do I want to implement this feature?” It means, as a developer, how do I wish this feature was already implemented. What do I want to have to do to use this feature in the future? How do I make it intuitive and easy to use?

Luckily, withRouter is not a difficult implementation, so neither is its hook.

I will want to import a hook from a default package export.

import useReactRouter from 'use-react-router';
Enter fullscreen mode Exit fullscreen mode

I want to call that hook and be done with it.

const { history, location, match } = useReactRouter();
Enter fullscreen mode Exit fullscreen mode

This declutters the history, location, and match props from my component and allows me to pick and choose which I want to have exist.

I do not want to have to implement further logic to re-render on location change. I want that functionality to be already implemented, in stark contrast to react-router's implementation of the HOC. That’s just personal preference, and you may agree with their implementation instead. This tutorial will discuss how to implement either, as it is a common feature request, and it was highly relevant to my personal use case.

The Implementation

The Dependencies 👶

First, our dependencies.

We will need the router context from the react-router package, as it contains the data we are wanting to access in our component, and we will need the useContext hook from the react package to “hook” to the Router Context. Since we are implementing pub-sub functionality, we also need the useEffect hook built into React. This will allow us to subscribe and unsubscribe from the location changes.

import { useContext, useEffect } from 'react';
import { __RouterContext } from 'react-router';
Enter fullscreen mode Exit fullscreen mode

Lastly, I am going to import useForceUpdate from the use-force-update NPM package. It is merely a shorthand for calling the useState hook to force a re-render, which is what we’ll be doing when the location changes.

import useForceUpdate from 'use-force-update';
Enter fullscreen mode Exit fullscreen mode

The Hook 🎣

With our dependencies imported, we can begin writing the hook

const useReactRouter = () => {
  const forceUpdate = useForceUpdate();
  const routerContext = useContext(__RouterContext);
  /* TODO */
  return routerContext;
};
Enter fullscreen mode Exit fullscreen mode

We begin by instantiating all other hooks that we’ll be needing. forceUpdate is a now a function that, when called, re-renders the component. routerContext is now the contents of the react-router context: an object with history, location, and match properties — the very same that you would expect to receive as props from withRouter.

If you do not want the re-rendering functionality, you can stop here. You can remove the forceUpdate variable, the useEffect import, and use-force-update dependency. I would advise using an external useReactRouter hook over calling useContext within your component solely because of the __RouterContext name and @next semvar currently needed to access React Router v4.4. Accessing this context may be subject to change, and making that adjustment in the single package is significantly less work than making that adjustment in every router-dependent component you use in your project. It is also ever-so-slightly more intuitive for developers to useReactRouter than useContext(__RouterContext) — the additional context import is redundant and unchanging.

The Pub-Sub 📰

To implement pub-sub behavior, we will want to useEffect. This will allow us to subscribe on component mount and unsubscribe on component unmount. Theoretically, it will unsubscribe from an old context and re-subscribe to a new one if the router context were to change (a desirable behavior if that were to happen), but there’s no reason to assume that will ever happen.

Replace our /* TODO */ with the following:

useEffect(
  () => {
    // TODO: subscribe
    return () => {
      // TODO: unsubscribe
    };
  },
  [ /* TODO: memoization parameters here */ ]
);
Enter fullscreen mode Exit fullscreen mode

useEffect takes a function that will execute every mount and update. If that function returns a function, that second function will execute every unmount and pre-update.

Where effect1 is the outer function and effect2 is the inner function, the component lifecycle executes like so:

mount > effect1 ... effect2 > update > effect1 ... effect2 > unmount
Enter fullscreen mode Exit fullscreen mode

The outer function executes immediately after a mount or update. The inner function waits until the component is about to update or unmount before executing.

Our goal is to subscribe to location changes once our component has mounted and unsubscribe to location changes just before our component unmounts.

The memoization array of useEffect says “do not execute these effect functions on update unless this array of parameters has changed.” We can use this to not continuously subscribe and unsubscribe to location changes just because the component re-rendered. As long as the router context is the same, we do not need to alter our subscription. Therefore, our memoization array can contain a single item: [ routerContext ].

useEffect(
  function subscribe() {
    // TODO
    return function unsubscribe() {
      // TODO
    };
  },
  [ routerContext ]
);
Enter fullscreen mode Exit fullscreen mode

How do you subscribe to location changes? By passing a function to routerContext.history.listen, that function will execute every time the router history changes. In this case, the function we want to execute is simply forceUpdate.

useEffect(
  function subscribe() {
    routerContext.history.listen(forceUpdate);
    return unsubscribe() {
      // TODO
    };
  },
  [ routerContext ]
);
Enter fullscreen mode Exit fullscreen mode

And how do you unsubscribe from location changes? We can’t just let this subscription exist after the component unmounts — forceUpdate will be called, but there won’t be a component to update!

routerContext.history.listen returns an unsubscribe function that, when called, removes the subscription listener (forceUpdate) from the event.

useEffect(
  () => {
    const unsubscribe = routerContext.history.listen(forceUpdate);
    return () => {
      unsubscribe();
    };
  },
  [ routerContext ]
);
Enter fullscreen mode Exit fullscreen mode

Not that there is any benefit to this, but if you want to make this code a little shorter, you can:

useEffect(
  () => {
    const unsubscribe = routerContext.history.listen(forceUpdate);
    return unsubscribe;
  },
  [ routerContext ]
);
Enter fullscreen mode Exit fullscreen mode

And even shorter:

useEffect(
  () => routerContext.history.listen(forceUpdate),
  [ routerContext ]
);
Enter fullscreen mode Exit fullscreen mode

Where to Go From Here? 🔮

The HOC implementation of withRouter provided by the react-router package pulls history, location, and match from component props and gives them higher priority than the context API’s values. This is likely due to the <Route> component providing these as props, and match’s value needing to come from Route's path interpretation.

While I have not harnessed this in my package yet, I think a solid next step would be to use the hooked component’s props as a parameter to useReactRouter, allowing it to use the same prop prioritization.

Conclusion 🔚

If you want to contribute to this open-source package or see it in TypeScript, you can star it, fork it, open issues, or otherwise check it out on GitHub. You may use this package yourself via use-react-router on NPM.

If you liked this article, feel free to give it a heart or unicorn. It’s quick, it’s easy, and it’s free! If you have any questions or relevant great advice, please leave them in the comments below.

To read more of my columns, you may follow me on LinkedIn, Medium, and Twitter, or check out my portfolio on CharlesStover.com.

💖 💪 🙅 🚩
charlesstover
Charles Stover

Posted on June 5, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related