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

useQueryState throttled option causes state to reset when component rerenders #726

Open gijsroge opened 3 weeks ago

gijsroge commented 3 weeks ago

Context

What's your version of nuqs?

2.1.0

What framework are you using?

Which version of your framework are you using?

2.13.1

Description

Whenever the server finishes a request the component get's re-rendered and the useQueryState returns an old search state.

useQueryStates does not have this issue, seems to only be happening with useQueryState

If I read the docs I would assume this should not be the case https://nuqs.47ng.com/docs/options#throttling-url-updates

the state returned by the hook is always updated instantly, to keep UI responsive. Only changes to the URL, and server requests when using shallow: false, are throttled.

Reproduction

https://stackblitz.com/~/github.com/gijsroge/nuqs-throttle-issue?file=app/routes/_index.tsx

If you type in the search box you will notice the issue.

This issue is only apparent if you use the throttle feature.

franky47 commented 1 day ago

Thanks, sorry it took so long to get into this issue.

It looks like the Remix adapter needs to follow the same optimistic search params update we use in Next.js (and really all the other adapters should follow the same pattern).

Let's assume we're typing "ab" in quick succession. The root of the issue was that, when the first request (for the first character typed, "a") is in flight, Remix's useSearchParams still returns the stale empty value, while the internal state is optimistically updated. Before it has a chance to resolve, we queue in the full "ab" value.

When the "?search=a" loader returns, the URL updates along with useSearchParams to search: "a". This causes the internal state to sync back to that temporary value, causing the flicker in the input. When the URL update finishes after queuing and the loader delay, useSearchParams resolves to search: "ab" and the UI is now eventually consistent.

The fix for this in Next was to use the useOptimistic experimental React hook. It was possible because Next app router embeds a canary version of React where useOptimistic is available, something we can't enforce for Remix users who likely use React 18.

I did some tests by following the same pattern and hacking together a useOptimisticSearchParams hook for Remix, which also adds shallow: true | false support (see my post on Bluesky).

Currently, the shallow option doesn't have an effect in Remix, everything was as if shallow was set to false, in contradiction with the docs. According to the Remix team, that is intentional: when the URL changes, loaders should re-run. In our case it's an issue as nuqs assumes local client-only updates by default, and only opts-in to server updates via shallow: false if there is an actual use-case for SSR needing the search params in loaders or Server Components in Next.

Now as to why useQueryStates works: I think that's a double bug.. 😅 It looks like the sync mechanism fails at the right moment, skipping that temporary ?search=a resolution. Another thing to look into.