Running Effects When the URL Changes
3 min read

Running Effects When the URL Changes

If you've worked with single-page apps much, you'll likely have encountered JavaScript's History API, which exposes pushState and replaceState to allow you to modify the current URL without reloading the page.

This causes some interesting challenges with React, since a useEffect on window.location.href won't get evaluated until something triggers a re-render. To make matters more complicated, the History API doesn't natively provide a way to hook into state changes.

To solve this, we need to trigger a full re-render every time the URL changes. To start, we'll create a useOnHistoryChange hook that we'll call in the root element, and we'll rely on that to trigger our re-render when necessary. The nice part about this approach is that we don't need to use contexts everywhere to handle location changes, just a normal useEffect.

Let's look at what useOnHistoryChange looks like:

function uniqueString(length = 32) {
  return [...Array(length)].map(() => Math.random().toString(36)[2]).join('')

export default function useOnHistoryChange(fn) {
  const id = uniqueString(8)

  useEffect(() => {
    proxy(id, fn)

    return () => {
  }, [])

  // Happens when the user clicks back/forward
  useEventListener('popstate', () => fn())

We have two things going on here: a proxy, and a useEventListener on popstate. The proxy, which I'll explain momentarily, gives us a way to register/unregister multiple function to execute when pushState or replaceState get called. The listener on popstate complements these, since the user pressing Back/Forward won't trigger our function if it's only attached to history methods.

Now we can break apart the proxy and unproxy methods, which are somewhat complex!

function proxy(id, fn) {
  const isNewProxy = !window.history._proxies

  if (isNewProxy) {
    window.history._proxies = {}

  window.history._proxies[id] = fn

  if (!isNewProxy) return

  const historyFns = ['pushState', 'replaceState']

  historyFns.forEach((historyFn) => {
    window.history[historyFn] = new Proxy(window.history[historyFn], {
      apply: (target, thisArg, argArray) => {
        Object.entries(window.history._proxies).forEach(([_id, proxyFn]) => {
          // Wait for the page to actually change before executing
          setTimeout(() => proxyFn())
        return target.apply(thisArg, argArray)

function unproxy(id) {
  delete window.history._proxies[id]

First, it would be helpful to familiarize yourself with the Proxy object, which gives you a really flexible and powerful way of creating a wrapper around a class method. This is the foundation we use for intercepting pushState and replaceState calls.

Reading top-down, you'll notice that we push each newly registered function into a stack, and only actually create the proxy once. This is what allows us to register/deregister functions reliably, since we don't really have a way to remove a proxy once it has been created - and if we did, the code would end up being very similar to the above.

Generally, proxying native JavaScript functions is an anti-pattern, as is persisting data in window.history._proxies the way that we are, but until browsers natively implement onPushState and onReplaceState, this is the best we can do.

Anyway, if you look inside the historyFns loop, the code should start looking a lot more digestible: it replaces each method with a proxied version of it, and the proxy loops through each registered function and executes it before executing the original history method. The important thing to note is the setTimeout around the actual function call, since this function actually runs before the original history method is called. If you're unfamiliar with this syntax, setTimeout without a second argument causes the given function to be run after everything else has finished executing.

Once you have all of this working, the last important piece is to make sure all of your useEffect calls have cleanup functions. If the page changes while an async request is running, you need to make sure it gets canceled, or you'll end up running into the tricky Warning: Can’t perform a React state update on an unmounted component. error, since the URL change could unmount the component that's currently waiting for an async response.

One last thing to note with this approach is that if you have your useEffect on window.location.href, and pushState or replaceState are called with a URL that's identical to the current one, a re-render will be triggered, but the useEffect will not, since the URL hasn't changed. This likely won't be an issue for you, but could easily create an edge case with certain user interactions.

Now that you've made it to the end of this mini-tutorial, it's worth noting that React Router will usually be a simpler and more idiomatic way to handle page changes. But if you have a unique use case, or don't want the overhead of a full library, useOnHistoryChange is a very viable alternative.