Skip to content

fix: make useParams() reactive on client-side navigation#489

Merged
james-elicx merged 3 commits intocloudflare:mainfrom
NathanDrake2406:fix/use-params-reactivity
Mar 12, 2026
Merged

fix: make useParams() reactive on client-side navigation#489
james-elicx merged 3 commits intocloudflare:mainfrom
NathanDrake2406:fix/use-params-reactivity

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Mar 12, 2026

Summary

  • useParams() was reading a module-level variable directly instead of subscribing via useSyncExternalStore, so navigating between dynamic routes (e.g. /posts/1/posts/2) wouldn't re-render components using useParams()
  • useParams() now uses useSyncExternalStore with the shared listener system, matching usePathname() and useSearchParams()
  • setClientParams() now calls notifyListeners() when params actually change, so the external store re-checks snapshots

Test plan

  • E2E: useParams updates reactively on client-side Link navigation (/1/2)
  • E2E: useParams updates across multiple consecutive navigations (/1/2/3/1)
  • E2E: useParams updates on browser back/forward history navigation
  • Unit: existing 659 shim tests pass (verified locally)
  • Typecheck and lint clean (verified locally)

useParams() was reading a module-level variable directly instead of
subscribing via useSyncExternalStore, so navigating between dynamic
routes (e.g. /posts/1 → /posts/2) wouldn't re-render components.

Two changes:
- useParams() now uses useSyncExternalStore with the shared listener
  system, matching usePathname() and useSearchParams()
- setClientParams() now calls notifyListeners() when params change,
  so the external store knows to re-check snapshots
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 12, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@489

commit: 2766bca

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88e58f6c2c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/navigation.ts Outdated
};
},
() => _clientParams as T,
() => (_getServerContext()?.params ?? {}) as T,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return a stable empty snapshot in useParams

When _getServerContext() is null (the file already notes this can happen during SSR/hydration fallback paths), this getServerSnapshot callback creates a fresh {} each call. useSyncExternalStore relies on snapshot identity stability during hydration, so this can cause hydration mismatch churn/warnings and extra renders for useParams() consumers whenever nav context is missing. Use a cached module-level empty params object for the fallback, similar to the cached empty server search params path.

Useful? React with 👍 / 👎.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix — useParams() absolutely needs useSyncExternalStore to be reactive on client-side navigation, matching the existing usePathname() and useSearchParams() patterns. The test coverage is solid (forward nav, multi-hop, back/forward). Two issues below, one of which can cause real bugs.

Comment thread packages/vinext/src/shims/navigation.ts Outdated
};
},
() => _clientParams as T,
() => (_getServerContext()?.params ?? {}) as T,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: unstable getServerSnapshot will cause hydration churn

When _getServerContext() returns null (which the comment on line 373 says can happen), this creates a fresh {} on every call. useSyncExternalStore compares snapshots with Object.is, so two {} objects are never equal → infinite re-render loop during SSR hydration fallback.

The existing useSearchParams hook handles this exact pattern with a cached empty fallback (_cachedEmptyServerSearchParams at line 261). useParams should do the same:

Suggested change
() => (_getServerContext()?.params ?? {}) as T,
() => (_getServerContext()?.params ?? _EMPTY_PARAMS) as T,

With a module-level constant:

const _EMPTY_PARAMS: Record<string, string | string[]> = {};

Place it near line 300 alongside the other cached client params state.

Comment thread packages/vinext/src/shims/navigation.ts Outdated
_listeners.delete(cb);
};
},
() => _clientParams as T,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: extract snapshot to a named function for consistency

Both usePathname() and useSearchParams() use named module-level snapshot functions (getPathnameSnapshot, getSearchParamsSnapshot) instead of inline arrows. This avoids creating a new closure on every render call. It doesn't affect correctness (React stores the latest snapshot function), but it's more consistent with the existing code style and marginally cheaper.

Suggested change
() => _clientParams as T,
getClientParamsSnapshot as () => T,

With:

function getClientParamsSnapshot(): Record<string, string | string[]> {
  return _clientParams;
}

Comment thread packages/vinext/src/shims/navigation.ts Outdated
Comment on lines +376 to +381
return React.useSyncExternalStore(
(cb: () => void) => {
_listeners.add(cb);
return () => {
_listeners.delete(cb);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: repeated subscribe boilerplate

This is the third copy of the identical subscribe function (also in usePathname at line 334 and useSearchParams at line 355). Not a blocker for this PR, but a follow-up extracting a shared subscribeToNavigation helper would clean things up:

function subscribeToNavigation(cb: () => void): () => void {
  _listeners.add(cb);
  return () => { _listeners.delete(cb); };
}

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 12, 2026

Review submitted. Here's a summary of the feedback:

Overall: Good fix. useParams() was correctly identified as missing useSyncExternalStore, and the implementation follows the existing patterns from usePathname() and useSearchParams(). Tests cover the right scenarios.

One bug to fix:

  • Unstable getServerSnapshot (line 384): When server context is null, () => ({}) creates a new object every call. Since useSyncExternalStore compares with Object.is, two {} are never equal, causing infinite re-renders during hydration fallback. Should use a cached module-level _EMPTY_PARAMS constant, matching how useSearchParams handles it with _cachedEmptyServerSearchParams.

Two style nits:

  • Extract the inline getSnapshot arrow to a named module-level function (getClientParamsSnapshot) for consistency with getPathnameSnapshot/getSearchParamsSnapshot.
  • The subscribe callback is now duplicated 3 times — could be extracted to a shared subscribeToNavigation helper in a follow-up.

github run

- Add _EMPTY_PARAMS constant to avoid creating a new {} on every
  getServerSnapshot call (Object.is would never match, causing
  hydration churn)
- Extract getClientParamsSnapshot/getServerParamsSnapshot named
  functions for consistency with usePathname/useSearchParams
- Extract subscribeToNavigation helper to deduplicate the identical
  subscribe callback across all three hooks
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clean, correct fix. The bug diagnosis is accurate — useParams() was reading _clientParams directly without useSyncExternalStore, so client-side navigations between dynamic routes silently failed to trigger re-renders.

The implementation follows the established patterns from usePathname() and useSearchParams() exactly:

  • useSyncExternalStore with a named snapshot function (getClientParamsSnapshot)
  • Stable _EMPTY_PARAMS module-level constant for the server snapshot fallback (avoiding the hydration churn bug from () => ({}))
  • notifyListeners() in setClientParams() only when params actually change (leveraging the existing JSON comparison guard)
  • Shared subscribeToNavigation helper extracted and used across all three hooks

All three issues from the earlier reviews (unstable server snapshot, inline snapshot arrow, duplicated subscribe boilerplate) are addressed in this revision.

E2E tests cover the right scenarios: forward navigation, multi-hop chains, and browser history back/forward. The fixture page is minimal and focused.

LGTM.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 12, 2026

Review submitted and approved.

The PR is a clean, correct fix for useParams() reactivity on client-side navigation. The author addressed all three issues from the earlier automated reviews (unstable server snapshot, inline snapshot arrow, duplicated subscribe boilerplate) in this revision. The implementation follows the established patterns in the codebase exactly, and the E2E tests provide solid coverage of forward navigation, multi-hop chains, and browser history back/forward.

github run

@james-elicx james-elicx merged commit 9bd7f81 into cloudflare:main Mar 12, 2026
17 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/use-params-reactivity branch March 18, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants