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

Set query state causes that component to rerender 4 times #755

Open kanasva opened 1 week ago

kanasva commented 1 week ago

Context

What's your version of nuqs?

2.1.1

What framework are you using?

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 23.6.0: Fri Jul  5 17:54:52 PDT 2024; root:xnu-10063.141.1~2/RELEASE_ARM64_T8103
  Available memory (MB): 16384
  Available CPU cores: 8
Binaries:
  Node: 22.10.0
  npm: 10.9.0
  Yarn: 1.22.22
  pnpm: 9.12.2
Relevant Packages:
  next: 15.0.3 // Latest available version is detected (15.0.3).
  eslint-config-next: 15.0.3
  react: 19.0.0-rc-66855b96-20241106
  react-dom: 19.0.0-rc-66855b96-20241106
  typescript: 5.6.3

Description

Setting query state causes that component to rerender for 4 times both in development and production on my local machine. However, in nuqs version 2.0.4, it rerenders 2 times.

Reproduction

Example: Steps to reproduce the behavior:

// page.tsx

import { Suspense } from "react";
import QueryState from "./QueryState";

export default function page() {
  return (
    <div>
      <Suspense>
        <QueryState />
      </Suspense>
    </div>
  );
}
// QueryState.tsx

"use client";

import { parseAsInteger, useQueryState } from "nuqs";
import { useRef } from "react";

export default function QueryState() {
  const count = useRef(0);
  const [, setQueryState] = useQueryState(
    "queryState",
    parseAsInteger.withDefault(0)
  );

  // Computation expensive to test
  const computeExpensiveValue = () => {
    let total = 0;
    for (let i = 0; i < 1e8; i++) {
      total += Math.sqrt(i);
    }
    return total;
  };

  console.log("Render", (count.current += 1));
  console.time("computeExpensiveValue"); // Start timing
  computeExpensiveValue();
  console.timeEnd("computeExpensiveValue"); // End timing

  return (
    <button
      onClick={() =>
        setQueryState((prev) => {
          count.current = 0;
          return prev + 1;
        })
      }
    >
      Set queryState + 1
    </button>
  );
}
  1. Click the 'Set queryState + 1' button and see the console
franky47 commented 1 week ago

Thanks for the report, it sounds like this is a side-effect of the optimistic useSearchParams update we did in #718.

The order of renders looks like the following:

  1. The internal state is immediately updated and returned
  2. When the URL update queue is flushed, the optimistic searchParams is updated
  3. When the URL has finished updating, the stock useSearchParams from Next.js picks up the change and re-renders
  4. Not sure what that 4th render comes from

One vector of optimisation would be to set the optimistic search params in the same tick as the internal state update, I'll see what I can do about that.

As an aside regarding computationally expensive operations that block the main thread, you might want to consider plugging them onto a deferred value to avoid race conditions, see #722.