Search code examples
javascriptreactjsreact-hooksreact-routerreact-router-dom

React Router useNavigate with a useEffect hook - proper way to use?


I'm new to React and trying to make a loading/greeting page that navigates on to the next after a few seconds of being shown. In React Router v6, we have the useNavigate() hook to allow you to control the navigation, and I am using this to successfully call the navigate function by setting a timeout in a useEffect() hook. However, the compiler is complaining that I have a missing dependency. I only want it to run once though, not whenever the navigate changes. What is the best way to do this?

Thanks!

import { useEffect } from "react";
import { useNavigate } from "react-router-dom";

function Greeting(props) {
  const navigate = useNavigate();
  useEffect(() => {
    setTimeout(() => navigate(props.nextPage), 3000);
  }, []);

  return (
    <div className="Greeting">
      <div>Hello World!</div>
    </div>
  );
}

export default Greeting;

Line 9:6: React Hook useEffect has a missing dependency: 'navigate'. Either include it or remove the dependency array react-hooks/exhaustive-deps


Solution

  • The useEffect hook is missing dependencies, both the navigate function and props.nextPage that are referenced in the callback. The linter warning is informing you to add them to the dependency array. The navigate function is an external reference (to the effect) so it should be included as a dependency, so this leaves the nextPage prop value that should also be included so it's re-enclosed in the timeout callback. Don't forget to return a cleanup function in the case that the component unmounts prior to the timeout expiring on its own.

    useEffect(() => {
      const timerId = setTimeout(() => navigate(props.nextPage), 3000);
      return () => clearTimeout(timerId);
    }, [navigate, props.nextPage]);
    

    As a general rule you should follow all guidance from the linter. The react-hooks/exhaustive-deps rule is there to help you write better code. Don't disable the rule for that line unless you are absolutely sure and are aware of what future consequences may arise if/when you ever update the callback logic. Disabling the linter rule will potentially mask future issues if the implementation changes and you do actually want to run the effect more often. My rule for this occasion is to add a comment to my future self and other team members to be aware they should check the dependency array.

    Example:

    useEffect(() => {
      const timerId = setTimeout(() => navigate(props.nextPage), 3000);
     
      return () => clearTimeout(timerId);
     
      // NOTE: Run effect once on component mount. Re-check dependencies
      // if effect logic is updated.
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);
    

    The extended answer is that the navigate function returned by the useNavigate hook is sometimes stable and sometimes not.

    https://github.com/remix-run/react-router/blob/react-router%407.1.5/packages/react-router/lib/hooks.tsx#L226-L231

    export function useNavigate(): NavigateFunction {
      let { isDataRoute } = React.useContext(RouteContext);
      // Conditional usage is OK here because the usage of a data router is static
      // eslint-disable-next-line react-hooks/rules-of-hooks
      return isDataRoute ? useNavigateStable() : useNavigateUnstable();
    }
    

    The useNavigateUnstable hook does have a dependency on the location.pathname:

    function useNavigateUnstable(): NavigateFunction {
      ...
    
      let { pathname: locationPathname } = useLocation();
    
      ...
    
      let navigate: NavigateFunction = React.useCallback(
        (to: To | number, options: NavigateOptions = {}) => {
          warning(activeRef.current, navigateEffectWarning);
    
          // Short circuit here since if this happens on first render the navigate
          // is useless because we haven't wired up our history listener yet
          if (!activeRef.current) return;
    
          if (typeof to === "number") {
            navigator.go(to);
            return;
          }
    
          let path = resolveTo(
            to,
            JSON.parse(routePathnamesJson),
            locationPathname,
            options.relative === "path"
          );
    
          // If we're operating within a basename, prepend it to the pathname prior
          // to handing off to history (but only if we're not in a data router,
          // otherwise it'll prepend the basename inside of the router).
          // If this is a root navigation, then we navigate to the raw basename
          // which allows the basename to have full control over the presence of a
          // trailing slash on root links
          if (dataRouterContext == null && basename !== "/") {
            path.pathname =
              path.pathname === "/"
                ? basename
                : joinPaths([basename, path.pathname]);
          }
    
          (!!options.replace ? navigator.replace : navigator.push)(
            path,
            options.state,
            options
          );
        },
        [
          basename,
          navigator,
          routePathnamesJson,
          locationPathname,
          dataRouterContext,
        ]
      );
    
      return navigate;
    }
    

    Whereas useNavigateStable does not have this same dependency on the location pathname:

    function useNavigateStable(): NavigateFunction {
      let { router } = useDataRouterContext(DataRouterHook.UseNavigateStable);
      let id = useCurrentRouteId(DataRouterStateHook.UseNavigateStable);
    
      let activeRef = React.useRef(false);
      useIsomorphicLayoutEffect(() => {
        activeRef.current = true;
      });
    
      let navigate: NavigateFunction = React.useCallback(
        async (to: To | number, options: NavigateOptions = {}) => {
          warning(activeRef.current, navigateEffectWarning);
    
          // Short circuit here since if this happens on first render the navigate
          // is useless because we haven't wired up our router subscriber yet
          if (!activeRef.current) return;
    
          if (typeof to === "number") {
            router.navigate(to);
          } else {
            await router.navigate(to, { fromRouteId: id, ...options });
          }
        },
        [router, id]
      );
    
      return navigate;
    }
    

    If you are using a Data router (i.e. one created using createBrowserRouter, createMemoryRouter, etc, and rendered by RouterProvider) the navigate function is provided as a stable reference and is likely safe to always be added as a React hook dependency.