While optimizing performance in one of my React.js projects I stumbled upon components re-rendering for no apparent reason whatsoever. After some experiments the culprit was found:
import { useNavigate } from "react-router-dom" // v6
const Component = () => {
const navigate = useNavigate()
...
}
Turns out that if you use the useNavigate
hook in a component, it will re-render on every call to navigate()
or click on <Link />
, even if the path has not changed. You cannot prevent it with the React.memo()
.
Here is a demonstration:
The first block does not call useNavigate
and is rendered only once. The second uses the hook and is re-rendered twice on every path “change” (I am not clear on why twice, maybe useNavigate
is to blame again 🤷). The third uses a “stable” version of useNavigate
, more on that below.
I would say that this is unexpected behavior, especially since useHistory
in react-router v5 did not cause re-renders. There is a long discussion on GitHub about this behavior. It boils down to the position that it is not a bug, but expected behavior:
It happens because useNavigate
subscribes to contexts that change when path change is triggered (even if it stays the same):
let { basename, navigator } = React.useContext(NavigationContext)
let { matches } = React.useContext(RouteContext)
let { pathname: locationPathname } = useLocation()
Usually, it is not a big problem, because changing the path means changing the view and you need to render a new set of components anyway. Re-rendering several menu elements is not a problem.
However, when you change parameters in the path without changing the view or there are a lot of constant components that are independent of the path change, it can become painful.
There are several ways to solve this problem:
-
Use the
useNavigate
hook in the smallest/lowest-level component possible. It will not save you from re-renders but makes them less costly. -
Decouple use of the hook from the component, if possible. For example, some of my components can trigger popups and notifications passing to them
navigate
function. I could move the hook to the popup and notification components themselves, although it would unnecessarily complicate otherwise simple setup. -
“Stabilize” the hook by putting it into a separate context and utilizing a mutable object from the
useRef
hook. This is a simplified version of this approach.
// StableNavigateContext.tsx
import { createContext, useContext, useRef, MutableRefObject } from 'react'
import { useNavigate, NavigateFunction } from 'react-router-dom'
const StableNavigateContext =
createContext<MutableRefObject<NavigateFunction> | null>(null)
const StableNavigateContextProvider = ({ children }) => {
const navigate = useNavigate()
const navigateRef = useRef(navigate)
return (
<StableNavigateContext.Provider value={navigateRef}>
{children}
</StableNavigateContext.Provider>
)
}
const useStableNavigate = (): NavigateFunction => {
const navigateRef = useContext(StableNavigateContext)
if (navigateRef.current === null)
throw new Error('StableNavigate context is not initialized')
return navigateRef.current
}
export {
StableNavigateContext,
StableNavigateContextProvider,
useStableNavigate,
}
// App.tsx
import { BrowserRouter } from 'react-router-dom'
import { StableNavigateContextProvider } from './StableNavigateContext'
export default function App() {
return (
<BrowserRouter>
<StableNavigateContextProvider>
// ...
</StableNavigateContextProvider>
</BrowserRouter>
)
}
// Component file
import { useStableNavigate } from './StableNavigateContext'
const Component = () => {
const navigate = useStableNavigate()
// ...
}
You can use a similar approach for the useLocation
hook or combine them in one context like in the original solution. However, since the components will not re-render on the path change anymore, their state may get stale.