Controlled store props (useStoreProps) cause state flicker
Description
Controlled store props such as open/setOpen and selectedId/setSelectedId can briefly commit an attempted value to the store, reset to the stale controlled prop, and then settle when React commits the new prop. This causes visible flicker, transient subscriber notifications, and focus reversals. The shared root cause is the useStoreProps synchronization mechanism.
Root Cause
useStoreProps currently treats a write to a controlled store key as committed state before the controlling prop accepts it. For example:
store.hide()commitsopen: falseand notifies subscribers.- The store subscription calls
setOpen(false). - If React has not committed the new prop when the batch listener runs,
useStorePropsresets the store to the staleopen: trueprop. - React later commits
open: false, and the store settles onfalse.
This produces a true → false → true → false flicker. Rejected and read-only updates still expose a speculative false → true transition.
The Tab reproduction follows the same sequence. Activating a focused tab requests a new route-derived selectedId, the stale route prop reasserts the previously selected tab, Tab logic moves focus to that stale selection, and the route commit finally applies and focuses the requested tab.
Affected Components
- Disclosure-based components using controlled
open/setOpen: Dialog, Popover, Menu, Hovercard, Tooltip, Select, Combobox popover, and others. - Tab components using controlled
selectedId/setSelectedId, particularly when the setter performs an asynchronous route transition. - Composite-family components using controlled
activeId, where a refused move can still trigger focus, virtual-focus, and scroll presentation. - Other controlled value/setter pairs synchronized through
useStorePropsshare the same request-versus-commit semantics and require targeted regression coverage when affected behavior is observed.
Reproductions
Controlled disclosure state
See the current reproductions in #3402, #4236, and #3496.
Route-controlled Tab selection
<TabProvider
selectedId={pathname}
setSelectedId={router.push}
selectOnMove={false}
/>- Select Vegetables.
- Press ArrowLeft so Fruits is focused while Vegetables remains selected.
- Press Enter.
On current main, Chromium emits this post-Enter focusin sequence:
Vegetables (+2.3 ms)
Fruits (+5.3 ms)Focus should remain on Fruits throughout the route transition. See the browser recording and the current-main verification and fix comparison from duplicate issue #6888.
Rejected controlled Composite move
A controlled activeId owner that refuses move can still have the requested item focused and scrolled into view. A refused move(null) can drop focus to document.body while the committed active item remains unchanged:
const [activeId] = useState("cell-1");
const store = useCompositeStore({ activeId, setActiveId: () => {} });See the current-main Chromium verification, prototype audit, and browser recording from duplicate issue #7402.
Historical active item case
The historical external setActiveId is overridden reproduction from #4100 fails with @ariakit/[email protected], but its exact steps pass on current main with React 18, React 19, and Chrome. Its store-based workaround remains relevant to controlled/uncontrolled transitions and setter-only observer semantics.
Related Issues
- #3402: Dialog's open state is not fully controllable.
- #4236: Popover opens when it should not.
- #3496: Popover cannot be controlled programmatically consistently.
- #6888: Route-controlled Tab selection briefly returns focus to the previous tab. Closed as a duplicate after the proposed request-versus-commit change removed the focus reversal.
- #7402: A refused controlled Composite move still focuses or scrolls to its rejected target. Closed as a duplicate after its exact regression contract was added here.
- #4100: Historical controlled
activeIdinitialization race. - #4213: External controlled Tab selection must continue moving focus to the newly selected tab.
Expected Behavior
A controlled store write should be a request. It should call the setter without changing the public store state. The controlling prop should be the only committed source of truth:
store.hide();
expect(setOpen).toHaveBeenCalledWith(false);
expect(store.getState().open).toBe(true);
// React later renders open={false}.
controller.commit(false);
expect(store.getState().open).toBe(false);Accepted updates should notify once when the prop commits. Rejected or read-only updates should not produce a public transition. Route-controlled Tab activation should not move focus away from the tab being activated while awaiting the route commit. A refused Composite move must not move real or virtual focus or scroll to the requested item or root. A delayed accepted move must still present its target, including a late-registering item.
Workaround
For controlled disclosure close behavior, see the component-level workaround.
A synchronous controller avoids the Tab focus reversal, but there is no equivalent general workaround for route-backed selection.
For rejected Composite keyboard navigation, refuse the key on each CompositeItem before it becomes a move request:
<CompositeItem moveOnKeyPress={() => false}>One</CompositeItem>This suppresses every keyboard move from that item, so use it only when that broad behavior is acceptable.
Planned Solution
Implement a request-versus-commit model at the core store layer:
- A write to a controlled key calls the current setter but does not mutate public state.
- A controller commits the controlling prop, updates public state, and notifies derived state and subscribers exactly once.
- Rejected and read-only updates produce no public transition.
- Sequential and functional writes before a commit derive from a private pending request value.
- Composite presentation must use the settled request outcome rather than the
movescounter alone, so refused requests have no focus or scroll effect while delayed accepted requests still present their target. - Controllers are lifecycle-scoped, work across composed stores, preserve SameValue and
NaNbehavior, and define controlled-to-uncontrolled transitions. Setter-only observer mode remains uncontrolled.
Deferring the reset to a microtask or timer is not the planned solution. It only shifts reconciliation, remains dependent on scheduler timing, and introduced a Tooltip regression in the timer experiment. See the architectural analysis and distinguishing test contract.
A request-versus-commit prototype implements this direction. The change is expected to be runtime-breaking and is targeted for v0.5.0.
Source: ariakit/ariakit