github reduxjs/react-redux v9.4.0-alpha.1

pre-release4 hours ago

This alpha release fixes a bug where useSignalSelector stopped updating when a memoized Reselect selector was shared between components or called with the same state twice, changes how array methods called on tracked state return their elements, and adds a per-evaluation read cache plus a dev-mode warning for selectors that repeatedly read the same field in a loop. The stock Provider and useSelector are unchanged.

npm install react-redux@alpha
yarn add react-redux@alpha
pnpm add react-redux@alpha

This is still an alpha. Please try it out and give us feedback, especially on real apps using Reselect and entity adapters. The alpha.0 notes cover what useSignalSelector is and how to opt in.

Changelog

Shared and memoized selectors now update correctly

After the alpha.0 release, we got feedback asking about two cases: reusing the same memoized selector instance across components, and conditional logic inside of a selector altering what fields it reads.

In alpha.0, every useSignalSelector evaluating against the same state object received the same tracking proxy. Reselect's argsMemoize (the weakMapMemoize default, and lruMemoize) keys its cache on argument identity, so the second evaluation with that proxy was a cache hit: the input selectors never ran, no state properties were read, and the hook recorded zero dependencies. That hook then never re-rendered.

This affected more than the reported case of two components sharing one createSelector. A single component with an inline wrapper (useSignalSelector(s => selectSummary(s).total)), a parametrized selector (selectScaled(s, factor)), nested memoized inputs, and components mounted after the cache was warm all went deaf after their first update.

Every tracked evaluation now gets a fresh root proxy. The consequence is that argsMemoize misses on every tracked run and the input selectors execute each time. The result function still memoizes on the input values, so derived objects keep stable identities and are shared across components as before. Hand-rolled caches that never read state (if (cached) return cached) still cannot be tracked, but those are equally stale under useSelector.

We also reviewed conditional selectors (s => s.toggle ? s.foo : s.bar). Turns out those already worked :) Dependencies are re-recorded from scratch on every evaluation, so switching branches drops the old branch and picks up the new one.

Array methods return raw elements with identity dependencies

Once we fixed the cache bug and re-ran the benchmarks, we saw that the alpha.0 benchmark results were flawed. In particular, the derived-selectors benchmark had appeared to be a significant perf improvement over stock useSelector. However, with the cache fix in place, the correct behavior had more overhead. More hooks actually received updates, and result functions that iterated arrays of tracking proxies (selectAll, then .filter over the result) paid one proxy trap and one dependency registration per element read, per hook, per dispatch. Inside a Reselect result function that precision buys nothing, since the function re-runs on input identity anyway.

To address this, find, findLast, filter, slice, and now map return raw state objects instead of proxies, each with a single identity dependency on the element's path (items.{id:42} for arrays with a key field, items.3 otherwise). Because state is immutable, an identity dependency never misses a change to that element; it can only over-run when a field you did not read changes. Fields read off a returned element are not tracked separately. Direct index access (state.items[0].name) keeps field-level precision.

For map, callbacks that return the element itself, a primitive, or a proxy obtained elsewhere (ids.map(id => entities[id])) get precise dependencies. Callbacks that construct new objects fall back to one dependency on the whole array. forEach, reduce, and flatMap are still not overridden and depend on the whole array.

One side effect: elements returned from these methods compare correctly with === against references held outside the selector, without unwrap().

Repeated reads are memoized, with a dev warning

A selector like comments.filter(c => c.postId === post.id) reads post.id once per comment. When post is a tracking proxy, each read is a trap call. Within one evaluation that read is idempotent: the value cannot change and the dependency is already linked. Each proxy now remembers its last primitive read for the current evaluation and returns it directly on repeat.

In development, a selector that re-reads the same field 100+ times in a single evaluation logs a warning once, naming the path and suggesting hoisting the value into a local before the loop. Hoisting is still cheaper than the memo, but the memo recovers most of the difference without code changes.

Performance

Overall, the branch is still a significant perf win across most of our benchmark scenarios, and it now correctly handles additional common usage scenarios that we'd expect to see in real app code.

10 s per scenario, this release vs 9.3.0, Chrome, react-dom/profiling. "Script" is total scripting time; "blocked" is main-thread time inside dispatch().

Scenario Script Blocked Notes
tree-view -55% -76%
entity-list-array -49% -85% stock drops frames
many-components-many-slices -37% -56%
deeptree-nested-hooks -28% -63%
realistic-slice-count -26% -80%
price-ticker -21% -59%
forms -20% -81%
multi-selector-component -17% -84%
selective-update -16% -66%
rapid-dispatch -14% -65%
entity-list +2% -21% noise
many-components-same-slice +3% +93% flat; work moved into dispatch
rtkq-separate-queries +9% +25%
one-component-many-slices +19% +22% 20,000 top-level keys
derived-selectors +36% +119% see below

Render counts match 9.3.0 within noise on every scenario.

The alpha.0 notes reported derived-selectors at -87%. That number was wrong: 150 of the 200 hooks in that scenario were affected by the cache bug and never re-rendered after their first update. With the fix, every hook re-runs its input selectors through the tracking proxy on every relevant dispatch, and that is the cost shown.

one-component-many-slices improved from +36% in alpha.0 to +19%.

Bundle cost is unchanged from alpha.0: zero for apps that do not import useSignalSelector, about +7 kB min+gz for apps that do.

Docs

The useSignalSelector API page now covers array-method return values, the Reselect interaction, and the repeated-read behavior.

What's Changed

Full Changelog: v9.4.0-alpha.0...v9.4.0-alpha.1

Don't miss a new react-redux release

NewReleases is sending notifications on new releases.