47ng / nuqs

Type-safe search params state manager for React frameworks - Like useState, but stored in the URL query string.
https://nuqs.47ng.com
MIT License
4.86k stars 104 forks source link

Next.js Pages Router is reset to intermediate state on Nuqs 2 #722

Closed bartlangelaan closed 2 weeks ago

bartlangelaan commented 3 weeks ago

Context

What's your version of nuqs?

    "nuqs": "^2.0.4",

What framework are you using?

Which version of your framework are you using?

Operating System:
  Platform: linux
  Arch: x64
  Version: #1 SMP PREEMPT_DYNAMIC Sun Aug  6 20:05:33 UTC 2023
  Available memory (MB): 4102
  Available CPU cores: 2
Binaries:
  Node: 20.9.0
  npm: 9.8.1
  Yarn: 1.22.19
  pnpm: 8.10.2
Relevant Packages:
  next: 14.2.1 // An outdated version detected (latest is 15.0.2), upgrade is highly recommended!
  eslint-config-next: 14.2.1
  react: 18.2.0
  react-dom: 18.2.0
  typescript: 5.4.5
Next.js Config:
  output: N/A
 ⚠ An outdated version detected (latest is 15.0.2), upgrade is highly recommended!
   Please try the latest canary version (`npm install next@canary`) to confirm the issue still exists before creating a new issue.
   Read more - https://nextjs.org/docs/messages/opening-an-issue

Description

When using useQueryState in the pages router, and calling the updater frequently, sometimes the state gets set to an intermediate value. It looks like the useSearchParams has a small delay, and overrides the state from nuqs.

Take this component as example:

import { useQueryState } from "nuqs";
export default function Homepage() {
  const [value, setValue] = useQueryState("value", { defaultValue: "" });
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

When typing 'test', it sometimes ends up with 'tet'.

Debug log:

[HMR] connected
index.js:8 [nuqs `value`] render - state: null, iSP: null
index.js:8 [nuqs `value`] render - state: null, iSP: null
index.js:8 [nuqs `value`] subscribing to sync
index.js:8 [nuqs queue] Enqueueing value=t Object
index.js:8 [nuqs `value`] updateInternalState t
index.js:8 [nuqs `value`] render - state: t, iSP: null
index.js:8 [nuqs `value`] render - state: t, iSP: null
index.js:8 [nuqs queue] Scheduling flush in 0 ms. Throttled at 50 ms
index.js:8 [nuqs queue] Flushing queue Array(1)0: (2) ['value', 't']length: 1[[Prototype]]: Array(0) with options Objecthistory: "replace"scroll: falseshallow: truethrottleMs: 50[[Prototype]]: Object
index.js:8 [nuqs queue (pages)] Updating url: /?value=t
index.js:8 [nuqs queue] Enqueueing value=te Object
index.js:8 [nuqs `value`] updateInternalState te
index.js:8 [nuqs `value`] render - state: te, iSP: null
index.js:8 [nuqs `value`] render - state: te, iSP: null
index.js:8 [nuqs queue] Scheduling flush in 0 ms. Throttled at 50 ms
index.js:8 [nuqs queue] Flushing queue Array(1) with options Object
index.js:8 [nuqs queue (pages)] Updating url: /?value=te
index.js:8 [nuqs `value`] render - state: te, iSP: te
index.js:8 [nuqs `value`] render - state: te, iSP: te
index.js:8 [nuqs queue] Enqueueing value=tes Object
index.js:8 [nuqs `value`] updateInternalState tes
index.js:8 [nuqs `value`] render - state: tes, iSP: null
index.js:8 [nuqs `value`] render - state: tes, iSP: null
index.js:8 [nuqs `value`] render - state: tes, iSP: te
index.js:8 [nuqs `value`] render - state: tes, iSP: te
index.js:8 [nuqs queue] Scheduling flush in 0 ms. Throttled at 50 ms
index.js:8 [nuqs queue] Flushing queue Array(1) with options Object
index.js:8 [nuqs queue (pages)] Updating url: /?value=tes
index.js:8 [nuqs `value`] syncFromUseSearchParams te
index.js:8 [nuqs `value`] render - state: te, iSP: te
index.js:8 [nuqs `value`] render - state: te, iSP: te
index.js:8 [nuqs `value`] render - state: te, iSP: tes
index.js:8 [nuqs `value`] render - state: te, iSP: tes
index.js:8 [nuqs queue] Enqueueing value=tet Object
index.js:8 [nuqs `value`] updateInternalState tet
index.js:8 [nuqs `value`] render - state: tet, iSP: te
index.js:8 [nuqs `value`] render - state: tet, iSP: te
index.js:8 [nuqs `value`] render - state: tet, iSP: tes
index.js:8 [nuqs `value`] render - state: tet, iSP: tes
index.js:8 [nuqs queue] Scheduling flush in 0 ms. Throttled at 50 ms
index.js:8 [nuqs queue] Flushing queue Array(1) with options Object
index.js:8 [nuqs queue (pages)] Updating url: /?value=tet
index.js:8 [nuqs `value`] syncFromUseSearchParams tes
index.js:8 [nuqs `value`] render - state: tes, iSP: tes
index.js:8 [nuqs `value`] render - state: tes, iSP: tes
index.js:8 [nuqs `value`] render - state: tes, iSP: tet
index.js:8 [nuqs `value`] render - state: tes, iSP: tet
index.js:8 [nuqs `value`] syncFromUseSearchParams tet
index.js:8 [nuqs `value`] render - state: tet, iSP: tet
index.js:8 [nuqs `value`] render - state: tet, iSP: tet

Reproduction

https://codesandbox.io/p/devbox/vpnm88

Type in the field to see the bug in action.

franky47 commented 3 weeks ago

Thanks for the great report, much appreciated!

Looks like we could apply the same trick as in #718, I'll give it a try and report back.

franky47 commented 2 weeks ago

Looking at your example, the SlowComponent blocks the main thread, something which is not recommended by the React team.

If this is a way to mock actual slow render performance in your application, I'd suggest looking into startTransition and useDeferredValue, which can help deferring state updates with large impacts to when React is not busy processing user inputs.

As for nuqs, this isn't something that was introduced in v2, as I can reproduce it in your sandbox with nuqs@1.20.0. Another tip to improve performance is to lower the useQueryState(s) hooks as much as possible in your React tree. They are all synced together so you may even duplicate/separate calls to the same query key in different parts of your app. No need to lift the state up: it's already lifted as high as possible (the URL) for you.

Another thing you can try if you can't avoid slow renders, is to use uncontrolled inputs: this lets the browser deal with internal updates of the input state, and only gives you the onChange event handler, that you can pass feed to a startTransition to lower the rendering priority to yield to more important user events:

https://codesandbox.io/p/devbox/nuqs-forked-77szd3

<input
  defaultValue={value}
  onChange={(e) =>
    startTransition(() => {
      setValue(e.target.value);
    })
  }
/>

One caveat of uncontrolled inputs is that they won't be reactive to live changes of the URL from another source (<Link>, router calls, or another useQueryState(s) update elsewhere in the same page). But if they don't have to be (you're just interested in initialising it to the URL value on mount), they can be a great source of optimisation.