Fix Next.js component state not refreshing on navigation

By

Fix Next.js state that stays stale on navigation: use template.js or a pathname key in the App Router, key={router.asPath} in the Pages Router _app.js.

~~~

If your Next.js component keeps stale useState() values when you navigate to another URL, React reused the same component instance instead of mounting a new one. The fix depends on the router. In the App Router, move the state out of the layout, or swap layout.js for template.js. In the Pages Router, add key={router.asPath} to the Component in _app.js.

Here’s how I ran into this. My component had an useState() hook to set some variables, and the state was not updated when navigating with the router.

Why does this happen?

useState(initialValue) only uses the initial value on the first render. On a re-render, the existing state wins.

React decides whether a component is “the same” by its type and its position in the tree. If both match before and after a navigation, React keeps the instance and re-renders it with new props. Any state you derived from the old page sticks around on the new one.

App Router

In the App Router, a page under a dynamic segment does not have this problem. When you go from /posts/first-post to /posts/second-post, the [slug] value changed, and Next.js keys each route segment on its value, so it mounts app/posts/[slug]/page.js again and its state starts fresh. The query string is not part of that key though. Going from /posts?page=1 to /posts?page=2 keeps the same page instance, and its state with it.

Where you do see stale state is in layouts. A layout wraps many routes and stays mounted while the pages under it change, which is how a sidebar keeps its scroll position across navigations. But if you put useState() in a layout, or in a Client Component the layout renders, that state survives every navigation under it.

The first fix is to move that state down into the page, or into a component the page renders.

If the state really belongs at the layout level and you want a clean slate on every navigation, rename layout.js to template.js. A template renders the same wrapper, but Next.js creates a new instance for every route below it, so state and effects reset.

You can also key a single Client Component on the current path, so only that component remounts:

'use client'

import { usePathname } from 'next/navigation'
import Comments from './comments'

export default function Sidebar() {
  const pathname = usePathname()

  return <Comments key={pathname} />
}

If what looks stale is server data, not client state, call router.refresh() from next/navigation instead. That re-fetches the Server Components for the current URL without remounting the client tree. More detail is in how to force a page refresh in Next.js.

Pages Router

This is where I hit the problem, back when the Pages Router was the only router. Here the problem shows up in pages too, not just in shared wrappers: pages/posts/[slug].js renders the same page component for /posts/first-post and /posts/second-post, so React reuses it.

Turns out my custom _app.js, which I copied from the tutorial and was just used to add global styling to the app, had this code:

export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}

I changed it to:

import { useRouter } from 'next/router'

export default function App({ Component, pageProps }) {
  const router = useRouter()

  return <Component {...pageProps} key={router.asPath} />
}

and it worked again as expected.

I just had to add the path as key. router.asPath changes on every URL change, React treats a changed key as a different component, throws away the old instance, and mounts a new one. Fresh mount, fresh state.

If remounting the whole page is too much, reset just the state you care about, watching the route from inside the page:

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

export default function Post() {
  const router = useRouter()
  const [comments, setComments] = useState([])

  useEffect(() => {
    setComments([])
  }, [router.asPath])

  //...
}

The tradeoff

Remounting on every navigation is a blunt tool. All state in that component is lost, and every useEffect() runs again, including data fetching.

For my app the global key was fine, and it’s a one-line fix. In the App Router I’d start by moving the state where it belongs, or resetting only what needs resetting, and reach for template.js or a path key when I want a clean mount every time.

Tagged: Next.js · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about next: