Skip to content

fix: useRouter() returns stable singleton to prevent unnecessary re-renders#321

Merged
james-elicx merged 1 commit intomainfrom
fix/use-router-stable-singleton
Mar 7, 2026
Merged

fix: useRouter() returns stable singleton to prevent unnecessary re-renders#321
james-elicx merged 1 commit intomainfrom
fix/use-router-stable-singleton

Conversation

@james-elicx
Copy link
Copy Markdown
Collaborator

Summary

  • `useRouter()` in `next/navigation` was creating a new object literal on every call
  • Next.js returns the same router reference on every call — components relying on referential equality (e.g. `useMemo`/`useEffect` dependency arrays, `React.memo` bailouts) would re-render unnecessarily on every render cycle
  • Fix: hoist the router object to module level; all methods close over module-level state and carry no per-render data, so this is safe and exactly matches Next.js behavior

Changes

  • `packages/vinext/src/shims/navigation.ts`: Replace the inline object literal in `useRouter()` with a module-level `_appRouter` singleton
  • `tests/shims.test.ts`: Add two regression tests
    • Asserts `useRouter()` returns the same reference across multiple calls (`first === second === third`)
    • Asserts the singleton exposes all expected navigation methods (`push`, `replace`, `back`, `forward`, `refresh`, `prefetch`)

Testing

pnpm test tests/shims.test.ts   # 568 tests, all passing

…enders

Next.js returns the same router object reference on every call to
useRouter() from next/navigation. Our implementation was creating a new
object literal on each call, which breaks components that rely on
referential equality — useMemo/useEffect dependency arrays and
React.memo bailouts would fire unnecessarily on every render.

Hoist the router object to module level so useRouter() always returns
the same reference. All methods close over module-level state and carry
no per-render data, so this is safe.

Adds two regression tests: one asserting reference equality across
multiple calls, and one verifying the singleton exposes all expected
navigation methods.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 7, 2026

Open in StackBlitz

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

commit: 917cb0d

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 7, 2026

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

@james-elicx
Copy link
Copy Markdown
Collaborator Author

/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.

Clean, correct change. The fix is straightforward and the reasoning is sound.

What changed: The router object literal that was previously created inside useRouter() on every call is hoisted to a module-level const _appRouter. The function now returns that singleton directly.

Why it's safe: Every method on the router object (push, replace, back, forward, refresh, prefetch) closes over module-level functions (navigateImpl, withBasePath, toRscUrl, etc.) and reads from module-level or window-level state at call time — none of them capture any per-render data. There's no reason to create a new object per call.

Why it matters: Components that put router in a dependency array (useMemo, useEffect, useCallback) or are wrapped in React.memo comparing props would trigger unnecessary re-renders on every parent render, since {} !== {}. This matches how Next.js handles it.

Tests are good — referential equality check (===) across three calls, plus a method existence check. All 568 tests pass.

No issues found.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 7, 2026

PR approved. The change is clean and correct:

  • The fix: Hoists the router object from inside useRouter() to a module-level const _appRouter, so every call returns the same reference.
  • Why it's safe: All methods close over module-level state and read dynamic values (like window.location) at call time, not at creation time. No per-render data is captured.
  • Why it matters: Components using router in dependency arrays or React.memo would re-render on every cycle since {} !== {}. This matches Next.js behavior.
  • Tests: Two new regression tests — referential equality across calls + method existence check. All 568 tests pass.

github run

@james-elicx james-elicx marked this pull request as ready for review March 7, 2026 15:48
@james-elicx james-elicx merged commit 57fd78f into main Mar 7, 2026
23 checks passed
@james-elicx james-elicx deleted the fix/use-router-stable-singleton branch March 7, 2026 15:48
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.

1 participant