Alex Vipond
SHIFT + D

Don't list next/router in useEffect dependencies

Published about 2 months ago

It's a bad idea to include router (returned from next/router's useRouter) in useEffect dependencies.

router is stable across renders except right after anything in your app calls router.push(...). Every router.push call replaces the entire router object with a brand new object, and as soon as React sees this new object in the useEffect dependencies, it will run the effect again.

Often, this doesn't cause problems, because router.push typically takes the user to a new page. Any useEffects from the old page won't run again—they'll just get cleaned up.

But when you implement something like router.push({ query: { page: 2 } }) to change query parameters without leaving the page, then you accidentally re-run all the useEffects on that page that have router in their deps. It's a big problem for stuff like this:

import { useEffect } from 'react'
import { useRouter } from 'next/router'

function MyComponent() {
  const router = useRouter()

  useEffect(
    () => {
      // Do any kind of effectful work that should only happen
      // once per page view, like tracking page views, setting up
      // global event listeners, running animations, fetching data,
      // etc.
  
      router.push(...)
    },
    [
      // Including `router` in this deps array can make this effect
      // run more than once per page view. 
      router,
      // ...someOtherDeps
    ]
  )

  return (...)
}

To make matters worse, the router identity change itself won't trigger a React re-render. React will wait for the next piece of state to change, and then re-render. When or if React finally gets that new reason to re-render, it will notice the router object has changed, and re-run your effects.

The simplest fix to this nasty issue is to remove router from your deps, but the ESLint rule react-hooks/exhaustive-deps will (correctly) catch that as a potential bug.

Don't disable the ESLint rule—it's incredibly useful! Instead, you can work around the entire router problem by replacing router with uppercase Router, imported from next/router like so:

import { useEffect } from 'react'
import Router from 'next/router'

function MyComponent() {
  useEffect(
    () => {
      // Do any kind of effectful work that should only happen
      // once per page view, like tracking page views, setting up
      // global event listeners, running animations, fetching data,
      // etc.
  
      Router.push(...)
    },
    [
      // ESLint will recognize `Router` as a global, stable object,
      // and it will not ask you to include it in your deps array.

      // ...someOtherDeps
    ]
  )

  return (...)
}

A final note for Next 15+ users: all of these router behaviors apply for the Pages Router, but not the App Router. For the app router, you can import { useRouter } from 'next/navigation', and that router object is always stable across renders, including full-page re-renders, even after someone calls router.push.

This is the code you should write for the App Router:

import { useEffect } from 'react'
import { useRouter } from 'next/navigation'

function MyComponent() {
  const router = useRouter()

  useEffect(
    () => {
      // Do any kind of effectful work that should only happen
      // once per page view, like tracking page views, setting up
      // global event listeners, running animations, fetching data,
      // etc.
  
      router.push(...)
    },
    [
      // Including `router` in this deps array is safe for the App Router.
      router,
      // ...someOtherDeps
    ]
  )

  return (...)
}

ON THIS PAGE

Don't list next/router in useEffect dependencies