Potential approach to concurrent stores #356
Closed
matclayton
started this conversation in
Ideas
Replies: 1 comment 1 reply
|
Happy to explore alternative Store implementations if we're able, but this path will not work for our needs. We can see the following example break due to correctness in your proposed hook replacement: Recording.2026-08-16.114717.mp4// Reference code, just replace `useSelector` to see the break in your proposed hook
import { createAtom, useSelector } from '@tanstack/react-store'
import { startTransition, Suspense, use, useState } from 'react'
const atom = createAtom({ a: 0, b: 0 })
// Never resolve promise to simulate a suspended state
const never = new Promise<never>(() => { })
const selectA = (state: { a: number; b: number }) => state.a
const selectB = (state: { a: number; b: number }) => state.b
function Value({ mode }: { mode: 'a' | 'b' }) {
const value = useSelector(atom, mode === 'a' ? selectA : selectB)
// Suspend only after useSelector has rendered with the pending selector.
if (mode === 'b') {
use(never)
}
// This will continue to render the last value of the previous selector while suspended.
return <output data-testid="value">A:{value}</output>
}
function App() {
const [mode, setMode] = useState<'a' | 'b'>('a')
return (
<>
<button
type="button"
onClick={() => {
// Start a transition to switch the mode to 'b' and suspend the rendering of the Value component.
startTransition(() => setMode('b'))
}}
>
Switch
</button>
<button type="button" onClick={() => atom.set(prev => ({ a: prev.a + 1, b: 0 }))}>Add</button>
<Suspense fallback={<p>Loading</p>}>
<Value mode={mode} />
</Suspense>
</>
)
}AFAIK this cannot be implemented in userland. That said, I made a PR to ensure we don't regress on this in the future: #357 so if you give it another shot we'll be able to ensure this doesn't break :) |
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Problem
useStoreis built onuseSyncExternalStoreWithSelector. When a uSES store notifies, React schedules the re-render throughforceStoreRerender, which hard-codes SyncLane — so any transition containing a store update is de-opted to a fully synchronous, uninterruptible render.This hits TanStack Router hardest:
router-corewraps every navigation state flush in an injectablestartTransition, which react-router'sTransitionerwires toReact.startTransition.@tanstack/storeatoms; writes are batched into one notification.useStore.The transition therefore contains a uSES update, and the whole navigation renders synchronously in one long task.
isTransitioningis effectively decorative, and there is no app-level workaround because the de-opt happens below anything an app controls.Proposal
Replace uSES inside
useStorewith an ordinaryuseStateupdate, so a notification commits in whatever lane fired it. Public signature unchanged.@tanstack/storeuntouched —router-coredepends on read-after-write within a synchronous block. Thecomparebail-out is preserved, so render counts are unchanged.Trade-offs
Heads-up: it breaks a common test idiom
await act(async () => { await router.navigate(...) })deadlocks.actdrains default- and transition-lane work only once its callback returns, and 1.170.23's render acknowledgement (router._rendered, settled from a layout effect) makesnavigate()resolve from a render — so the two collide and the suite times out against an empty document.It is a harness artifact, not a production defect: verified with
IS_REACT_ACT_ENVIRONMENT = falseandcreateRoot, consecutive navigations each settle withrendered=trueand commit the DOM. The fix is fire-then-drain (act(() => { router.navigate(...) }); await act(async () => {})). We hit it in 19 call sites across 9 suites.Status
Implemented and running behind a default-off switch at Mixcloud, against
@tanstack/react-store@0.9.3: a TypeScript port, a parity suite running every shared case against both the real uSESuseStoreand this implementation, a transition de-opt canary, and our whole router + SSR suite (49 files / 307 tests) green with it installed and assertions unmodified.We have not measured the INP win in the field yet — the mechanism is established and the de-opt is demonstrated by test, but the rollout is still default-off, so we're not making a performance claim we haven't earned.
The implementation is below. Happy to paste the parity suite and the fuller write-up into this thread too.
Implementation
TypeScript port of our Flow implementation, typed against the published
0.9.3declaration rather than any internal helper we couldn't see. Behaviour-identical to what we run.useStore.ts (142 lines)
How it works
Three pieces of per-subscriber state:
box(React state holding the snapshot, fresh wrapper per notification — this is what makes the update lane-carried),selected(the returned value, memoised in a ref behind the caller'scompare), andannounced/announcedSnapshot(the last selection handed to React, and the snapshot it came from).Three paths touch them:
announced. Changed → record it andsetBox({ snapshot }), one state update in the firing lane. Unchanged → no re-render owed, but still advance the tracked snapshot so a later selector cannot derive from a snapshot the store has moved past.Three invariants, each of which we broke first and then pinned with a test:
router.stores.isLoadingaway from the router's ownisTransitioningand stalled every navigation's settle.useStructuralSharingmints a fresh selector closure every render, so a render-phase update produces yet another identity and loops until React throws "Too many re-renders".announcedrather than the committed selection is what makes a value that changes and reverts before React renders still deliver the reverted value.Questions
useStore, an opt-in export alongside it, or an option?react-routerown a concurrent binding until React ships native support? The bug is only observable through the router, but the fix is generic.useRouterStatealready bypassesuseStoreentirely whenisServer. Is that the intended story?All reactions