47ng/nuqs

Navigating between pages rerenders source page

Opened this issue · 9 comments

Context

What's your version of nuqs?

^1.17.1

Next.js information (obtained by running next info):

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 23.3.0: Wed Dec 20 21:31:00 PST 2023; root:xnu-10002.81.5~7/RELEASE_ARM64_T6020
Binaries:
  Node: 18.18.2
  npm: 9.8.1
  Yarn: 1.22.19
  pnpm: N/A
Relevant Packages:
  next: 14.1.3
  eslint-config-next: 14.1.3
  react: 18.2.0
  react-dom: 18.2.0
  typescript: 5.4.2
Next.js Config:
  output: N/A

Are you using:

  • ❌ The app router
  • ✅ The pages router
  • ❌ The basePath option in your Next.js config
  • ❌ The experimental windowHistorySupport flag in your Next.js config

Description

Navigating between pages renders both the source page and the target page. I would expect only the target page to render.

Reproduction

// pages/a.tsx
export default function A() {
  const [page] = useQueryState("page");
  console.info("[page a]", page);

  return <Link href="/b?page=b">b</Link>;
}

// pages/b.tsx
export default function B() {
  const [page] = useQueryState("page");
  console.info("[page b]", page);

  return <Link href="/a?page=a">a</Link>;
}

Navigate to /a.

[page a] null

Click on the link to b.

...
[page a] b
[page b] b

Click on the link to a.

...
[page b] a
[page a] a

As you can see both source and target pages are being rendered when I would expect only the target page to render.

If I replace useQueryState with useRouter it behaves as expected. Either I have misunderstood something about nuqs or this is a bug.

The first render on page a is from the URL sync mechanism. For some reason, the pages router won't unmount the source page until after navigation has finished (possibly for transition purposes), and when nuqs picks up a URL change, it sends the info to all mounted hooks, which includes the previous page.

For the record, the behaviour in the app router is even weirder: the source page behaves as expected, but the target page mounts and renders first with no search params (as it was before clicking the link), then navigation occurs, and the state is updated with the correct values from the URL.

You can see all of this by enabling logging.

Ah, that is annoying. Would it not be possible to somehow attach the pathname to the hooks (on mount) and then always compare the current pathname with the hook pathname before propagating changes? At least for the pages router. The app router behaviour seems more difficult to address.

This custom hook solves the problem for me:

export default function useSafeQueryState(key, options?) {
  const [state, setState] = useQueryState(key, options);
  const [safeState, setSafeState] = useState(state);
  const [pathname, setPathname] = useState<string>();
  const router = useRouter();

  useEffect(() => {
    // Set pathname on mount
    setPathname(router.pathname);
  }, []);

  useEffect(() => {
    function onRouteChangeStart(url) {
      // Change pathname when starting to navigate to a different page
      setPathname(url.split('?')[0]);
    }

    router.events.on('routeChangeStart', onRouteChangeStart);

    return () => {
      router.events.off('routeChangeStart', onRouteChangeStart);
    };
  }, [pathname, router.events]);

  useEffect(() => {
    if (
      pathname !== router.pathname ||
      state === safeState ||
      JSON.stringify(state) === JSON.stringify(safeState)
    ) {
      return;
    }

    setSafeState(state);
  }, [pathname, router.pathname, safeState, state]);

  return [safeState, setState];
}

EDIT: Accidentally reversed equality check in useEffect
EDIT 2: Fixed setting of pathname after testing

@mateogianolio Thanks for this snippet. We were having a similar issue where every state on our website that used useQueryState() rendered one more time with the default value before a page transition, resulting in a UI flash most noticeable on slower devices.

I hope something like this is fixed or added on a future release of the library.

⚠️ : Im using next 13.5.6 where asPath is actually what we want to use instead of pathname, so the hook became like this:

EDIT: this is valid when you have dynamic routes: see comment below

export default function useSafeQueryStates(
  key,
  options?
) {
  const [state, setState] = useQueryStates(key, options)
  const [safeState, setSafeState] = useState(state)
  const [pathname, setPathname] = useState<string>()
  const router = useRouter()
  
  const routerPathname = router.asPath.split('?')[0]

  useEffect(() => {
    // Set pathname on mount
    setPathname(routerPathname)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    function onRouteChangeStart(url: string) {
      console.log('onRouteChangeStart', { url })
      // Change pathname when starting to navigate to a different page
      setPathname(url.split('?')[0])
    }

    router.events.on('routeChangeStart', onRouteChangeStart)

    return () => {
      router.events.off('routeChangeStart', onRouteChangeStart)
    }
  }, [pathname, router.events])

  useEffect(() => {
    if (pathname !== routerPathname) {
      return
    }

    setSafeState(state)
  }, [pathname, routerPathname, safeState, state])

  return [safeState, setState]
}

@hamzaboularbah interesting. I'm on 13.5.6 and I didn't have to change anything and it worked fine. What were you experiencing? Maybe I missed it. Thanks.

@jmnunezizu Oh my bad, the issue arises when you have dynamic routes.

Say for example you have a page /something/123/list with 123 as a dynamic id (let's call it somethingId), in this case when you call the hook in that route, the url from onRouteChangeStart will be /something/123/list which means that the pathname state will also be that. But the router.pathname will be /something/[somethingId]/list.

So when you run pathname !== router.pathname will always be true and you will not update the state, hence using router.asPath that contains the actual values similar the the url string.

I mean in your case if you don't have a dynamic route with this hook, it should be fine since router.pathname and router.asPath will be the same.

@mateogianolio Thanks for this snippet. We were having a similar issue where every state on our website that used useQueryState() rendered one more time with the default value before a page transition, resulting in a UI flash most noticeable on slower devices.

I hope something like this is fixed or added on a future release of the library.

Hello ! do we have more information about it pls ? @franky47

For the reason why this happens, see this explanation by Tim Neutkens: https://x.com/timneutkens/status/1795809805774250336

Now for solutions in nuqs, I'd have to dive deeper into the issue, but I don't have a lot of free time at the moment.