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:
// https://stackoverflow.com/a/47496558
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 () => {
unproxy(id)
}
}, [])
// 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.