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.
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 main — byte-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.
Summary
useLinkPropssubscribes every client-sideLinkto the location store with an identity selector, then deriveshrefandisActivefrom the published location in downstream memos. The comparator can only ask "is this a different URL?", never "does this link care?" — so everyLinkon 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
routesByPathfast path inbuildLocation. That landed and helps.This is about the number of renders. The two are independent: after #2516 each
Linkrender is cheaper, but everyLinkstill renders on every navigation. In my measurements the remainingbuildLocationtime 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 diffedpackages/react-router/src/link.tsxagainst the published1.170.18tarball and againstmain— byte-identical in all three, so this applies to the current release and to unreleasedmain.Mechanism
src/link.tsx:403-407:currentLocationthen feeds two derivations, both of which list it as a dependency:So both recompute on every navigation and the component re-renders regardless of whether either result differs.
The
_isServerbranch 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, andactiveOptionsonly 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
hrefanddata-statuseither side of the navigation:hrefor active status changedThe 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'sPerformedWorkflag:Link~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:
buildLocationstill 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:
Linkpasses_fromLocation: currentLocationexplicitly, and inbuildLocationthat 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 tolatestLocation, 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, sincenextstill needs the location for relativetoresolution and param inheritance.Scope
Only
packages/react-routeris addressed.packages/solid-routerandpackages/vue-routerhave 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.tsxexists and I did not run it — it looks like the natural place for a perf guard if you want one.