Skip to content

Link re-renders on every navigation even when its href and active state are unchanged #7951

Description

@matclayton

Summary

useLinkProps subscribes every client-side Link to the location store with an identity selector, then derives href and isActive from the published location in downstream memos. The comparator can only ask "is this a different URL?", never "does this link care?" — so every Link on the page re-renders on every navigation, including the ones whose rendered output is byte-identical before and after.

On one page in our app (Mixcloud), 313 links persisted across a single navigation and exactly 1 changed its rendered output. The other 312 re-rendered for nothing.

Fix and regression test in #7952.

Not the same as #2011 / #2516

Worth separating up front, because it looks adjacent. #2011 and the work that came out of it (#2359#2516) reduced the cost of each render, via the routesByPath fast path in buildLocation. That landed and helps.

This is about the number of renders. The two are independent: after #2516 each Link render is cheaper, but every Link still renders on every navigation. In my measurements the remaining buildLocation time is already small — 1563 calls totalling 15.8ms — and the residue is the React render pass itself.

Version

Reproduced on @tanstack/react-router@1.170.17. I diffed packages/react-router/src/link.tsx against the published 1.170.18 tarball and against mainbyte-identical in all three, so this applies to the current release and to unreleased main.

Mechanism

src/link.tsx:403-407:

const currentLocation = useStore(
  router.stores.location,
  (l) => l,                                  // publishes the whole location
  (prev, next) => prev.href === next.href,   // fires on any URL change
)

currentLocation then feeds two derivations, both of which list it as a dependency:

const next = React.useMemo(() => {
  const opts = { _fromLocation: currentLocation, ..._options }
  return router.buildLocation(opts as any)
}, [router, currentLocation, _options])
const isActive = React.useMemo(() => { /* compares currentLocation against next */ }, [
  /* …, */ currentLocation, externalLink, isHydrated, next.hash, next.pathname, next.search, router.basepath,
])

So both recompute on every navigation and the component re-renders regardless of whether either result differs.

The _isServer branch already avoids this deliberately — its comment says it computes active status "by reading from the location store directly" specifically to "avoid creating any router-state subscriptions". The client path has no equivalent, and activeOptions only tunes how activeness is compared, never whether the component subscribes.

Measurements

Real app, ~578 anchors on the page, navigating between two sibling routes under the same layout. Only the leaf content and one nav tab change.

How much of the work is wasted — snapshotting each persisted anchor's href and data-status either side of the navigation:

links persisting across the navigation 313
whose href or active status changed 1
whose derived output was identical 312 (99.7%)

The one that changed was the tab being navigated to, gaining data-status="active".

What the wasted renders cost — per-commit selfBaseDuration, attributed by walking fibers carrying React's PerformedWork flag:

mount update
whole page 679.9ms 200.6ms
TanStack Link 97.6ms (759 mounts) 24.1ms (364 renders)

~24ms of ~880ms on that navigation, or ~12% of update-render time. On a lighter navigation back to the smaller list it was 16.5ms of 65.5ms update time.

Caveats so these aren't over-read: development build, so absolute numbers are inflated relative to production — the proportions are the reliable part. Single machine, single page. And the mount column dominates the update column for this page, so the case for fixing this is that it is ~100% waste and scales with link count, not that it is the largest number on the page.

Direction taken in the PR

Publish the location-derived values through the selector so the comparator can bail out, instead of publishing the location and deriving afterwards:

const { href, externalLink, isActive } = useStore(
  router.stores.location,
  selectLinkState,   // buildLocation + getHrefOption + isActive, memoized on router/_options/…
  compareLinkState,  // href === href && externalLink === externalLink && isActive === isActive
)

buildLocation still runs once per link per location change — what goes away is the React render and the host reconciliation under it.

One implementation note worth flagging, since it is why this isn't a one-liner: Link passes _fromLocation: currentLocation explicitly, and in buildLocation that is the head of a fallback chain (dest._fromLocation || this.pendingBuiltLocation || this.latestLocation, router-core/src/router.ts:1857). It is deliberately pinning resolution to the subscribed location rather than letting it fall through to latestLocation, which can differ mid-transition. The selector formulation preserves that, because it derives from the value being published — but a naive "subscribe to a boolean" would not, since next still needs the location for relative to resolution and param inheritance.

Scope

Only packages/react-router is addressed. packages/solid-router and packages/vue-router have the same shape and would want the same treatment; I have not touched them, so please don't assume they're covered.

packages/react-router/tests/link.bench.tsx exists and I did not run it — it looks like the natural place for a perf guard if you want one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions