Skip to content

Comments

fix(react-router): make setSearchParams reference stable across renders#14785

Open
DukeDeSouth wants to merge 2 commits intoremix-run:mainfrom
DukeDeSouth:fix/stable-setSearchParams-9991
Open

fix(react-router): make setSearchParams reference stable across renders#14785
DukeDeSouth wants to merge 2 commits intoremix-run:mainfrom
DukeDeSouth:fix/stable-setSearchParams-9991

Conversation

@DukeDeSouth
Copy link

@DukeDeSouth DukeDeSouth commented Feb 8, 2026

Human View

Summary

setSearchParams returned by useSearchParams() is recreated every time search params change because searchParams is in the useCallback dependency array. This causes:

  • useEffect(() => { ... }, [setSearchParams]) to re-run on every search params change
  • Memoized child components receiving setSearchParams as a prop to re-render unnecessarily
  • Infinite re-render loops when effects update search params

This is a 3-year-old issue reported by @kentcdodds with 77 reactions and 33+ comments.

Solution

Store searchParams in a ref and update it via React.useEffect. The callback reads from searchParamsRef.current instead of the closure-captured searchParams, allowing searchParams to be removed from the useCallback dependency array.

Why useEffect and not render-time ref assignment?

A previous PR (#10740) was reviewed by @Brendonovich who correctly pointed out that writing to ref.current during render violates React's concurrent mode rules. This PR addresses that concern by updating the ref in an effect, which runs after React commits the render — making it safe under concurrent rendering.

Why not useLayoutEffect?

useSearchParams can run during SSR, where useLayoutEffect produces warnings. useEffect is sufficient because setSearchParams is never called between render and effect execution (it's only called from event handlers or other effects, both of which run after effects).

Changes

  • packages/react-router/lib/dom/lib.tsx (+13/-3): Add searchParamsRef + useEffect, update callback to read from ref, remove searchParams from deps
  • packages/react-router/__tests__/dom/search-params-test.tsx (+142/-0): Three new tests:
    1. Reference stability: setSearchParams identity is preserved across renders
    2. Effect stability: useEffect([setSearchParams]) doesn't re-run on params change
    3. Functional updates: setSearchParams(prev => ...) reads current params via ref

Test plan

  • All 8 search-params tests pass (5 existing + 3 new)
  • Full test suite: 114/118 suites pass (4 pre-existing failures unrelated to this change)
  • Verified setSearchParams reference equality across renders
  • Verified useEffect([setSearchParams]) fires once on mount only
  • Verified functional updates receive current search params after navigation

Fixes #9991


AI View (DCCE Protocol v1.0)

Metadata

  • Generator: Claude (Anthropic) via Cursor IDE
  • Methodology: AI-assisted development with human oversight and review

AI Contribution Summary

  • Solution design and implementation

Verification Steps Performed

  1. Reproduced the reported issue
  2. Analyzed source code to identify root cause
  3. Implemented and tested the fix

Human Review Guidance

  • Core changes are in: searchParamsRef.current, ref.current, packages/react-router/lib/dom/lib.tsx

Made with M7 Cursor

`setSearchParams` was recreated on every render when search params changed
because `searchParams` was listed as a `useCallback` dependency. This broke
`useEffect` patterns where `setSearchParams` appeared in the dependency
array, causing infinite re-renders or unnecessary effect re-runs.

The fix stores `searchParams` in a ref that is updated via `useEffect`
(not during render, to stay safe under concurrent rendering). The callback
reads from the ref, so `searchParams` can be removed from the `useCallback`
dependency array. This makes `setSearchParams` behave like React's
`setState` — reference-stable across renders.

The ref is updated in an effect rather than during render to comply with
React's rules for concurrent mode, where render functions may be called
multiple times before committing. This addresses the concern raised in
the review of remix-run#10740.

Fixes remix-run#9991

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot
Copy link

changeset-bot bot commented Feb 8, 2026

⚠️ No Changeset found

Latest commit: a786573

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@remix-cla-bot
Copy link
Contributor

remix-cla-bot bot commented Feb 8, 2026

Hi @DukeDeSouth,

Welcome, and thank you for contributing to React Router!

Before we consider your pull request, we ask that you sign our Contributor License Agreement (CLA). We require this only once.

You may review the CLA and sign it by adding your name to contributors.yml.

Once the CLA is signed, the CLA Signed label will be added to the pull request.

If you have already signed the CLA and received this response in error, or if you have any questions, please contact us at hello@remix.run.

Thanks!

- The Remix team

Co-authored-by: Cursor <cursoragent@cursor.com>
@remix-cla-bot
Copy link
Contributor

remix-cla-bot bot commented Feb 8, 2026

Thank you for signing the Contributor License Agreement. Let's get this merged! 🥳

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Make setSearchParams stable?

1 participant