ARTICLE AD BOX
I’m debugging a subscription-ordering issue in React-Redux v9 (React 18, modern useSyncExternalStore-based subscriptions). I have a parent component that conditionally renders a child based on Redux state, and a delete action that removes that child’s item. It has another child, which stays there and just get's it's text updated
Here is the exact minimal repro (full file here):
function Parent() { const order = useSelector((s) => s.items.order); const hasItem = order.includes("1"); return ( <div> <Controls /> {hasItem ? <Child id="1" /> : <div>(Child not rendered)</div>} <h2>Child ID 1, maybe buggy after delete</h2> <Child id="2" /> </div> ); } function Child({ id }) { const text = useSelector((s) => s.items.byId[id].text); // may read deleted state return <div>Item text: {text}</div>; }Delete button:
dispatch(itemsSlice.actions.delete("1"));
Because modern React-Redux’s dispatch cycle is synchronous:
dispatch() → parentSubscription.onStateChange() → parent selector runs (detects delete) → schedules parent rerender → notifyNestedSubs() // immediately, same tick, before React commitI would expect Child1’s existing listener to still be active when notifyNestedSubs() runs, because:
React has not committed/unmounted Child1 yet
Cleanup (unsubscribe) should not have run yet
notifyNestedSubs() iterates the listener array synchronously
So Child1’s selector should run and throw:
TypeError: Cannot read properties of undefined (reading 'text')
Actual
Child2 receives the nested notification and re-runs its selector
Child1 never receives a notification at all
No error occurs
React unmount cleanup happens later, in commit, after nested notifications
What I already know
Relevant sources:
React-Redux Subscription system: https://github.com/reduxjs/react-redux/blob/master/src/utils/Subscription.ts
External store RFC (real hook, NOT the shim): https://github.com/reactjs/rfcs/pull/221
Shim implementation (uses effects, but real React does not): https://github.com/facebook/react/blob/main/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js
Possible explanations I’ve found
When the parent’s selected state slice changes, its useSyncExternalStore call produces a new subscribe function (because the subscribe argument identity changes). This causes React-Redux to treat the parent as having a new Subscription instance for the next render. The old subs.notifyNestedSubs chain becomes stale and will never fire again, because all new nested listeners must attach to the new subscription object created during this parent render.Since Child1 is removed in this render, it never runs useSyncExternalStore again and therefore never receives the new subscribe function from context. It never attaches itself to the new parent subscription tree.
The old subscription tree (where Child1 was registered) becomes orphaned. useSyncExternalStore cleans this up by calling the old unsubscribe during commit, because the subscribe argument changed.
Result: The notifyNestedSubs call for the new subscription only contains Child2 (the only child that re-rendered and re-subscribed). Child1 never appears in the new listener list and therefore never receives a selector call or throws an error.
But this deosn't seem to be true:
Because, React-Redux memoizes the subscribe function because the Subscription instance is memoized inside the Provider’s context. In Provider.tsx, the context value is created with useMemo: https://github.com/reduxjs/react-redux/blob/master/src/components/Provider.tsx#L22
const contextValue = React.useMemo(() => { const subscription = createSubscription(store) ... Reconciliation before notify - React might fully render/reconcile the parent (and drop Child1) before react-redux calls notifyNestedSubs(), even though dispatch is synchronous.New nested subscription tree built during parent render During synchronous parent render, only Child2 re-renders and calls subscribe(listener). Child1 never re-renders → never re-subscribes → the new listener list contains only Child2.
Counter: notifyNestedSubs should synchronously trigger re render of child1, no time for reconciliation here.
Some form of early unsubscribe - React Fiber might run the external-store cleanup earlier than expected (contradicting most docs).I also remeber v3 v4 redux code, where they actually force parent componentDidMount and then remove child listener if child is delted to avoid stale/zombie issue.
In v3/v4, I believe Redux triggered the parent’s setState first, causing React to rerender and unmount Child1. During unmount, Child1’s listener was removed. Only after this cleanup did the parent’s subscription run its notifyNestedSubs(), ensuring deleted children never received stale notifications. -- though this triigers UI painsts for every nested level - highly inefficient.
I have cretaed my own simple redux clone where I can replicate child2 triggereing erro for not being able to read text - https://github.com/AnupamKhosla/redux_under_the_hood/blob/main/REDUX_FREEZE_READONLY/redux_basic.js
Question:
What is the correct, source-level explanation for why Child1’s listener is not invoked by notifyNestedSubs() during the same synchronous dispatch tick, even though commit/unmount cleanup has not run yet?
Why doesn't Child1 try to render and calculate useSelect and fetch undefined state from deleted id?
