DEV Community

Cover image for Why useNavigate hook in react-router v6 triggers waste re-renders and how to solve it
shallowdepth
shallowdepth

Posted on • Originally published at shallowdepth.online

Why useNavigate hook in react-router v6 triggers waste re-renders and how to solve it

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();
    ...
}
Enter fullscreen mode Exit fullscreen mode

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.

Comment for #7634

timdorr avatar
timdorr commented on

useNavigate changes when the current location changes. It depends on it for relative navigation. Wrapping it in memo only prevents re-renders from parent components. If hooks within the component cause re-renders, there is nothing memo can do.

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();
Enter fullscreen mode Exit fullscreen mode

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:

  1. Use the useNavigate hook in the smallest/lowest-level component possible. It will not save you from re-renders but makes them less costly.

  2. 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.

  3. "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();
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)