Repository navigation
v9.4.0-alpha.0 #2400
markerikson
started this conversation in
General
v9.4.0-alpha.0
#2400
Replies: 0 comments
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.
This alpha release adds an opt-in
useSignalSelectorhook andSignalProvidercomponent that only re-run selectors whose state dependencies actually changed, plus areact-redux/signalsentry point for adopting them app-wide, and restructures the hooks API docs into separate pages.This is an early alpha. The implementation is complete and heavily tested, but it has only been tried in a small number of real apps. Please try it out and give us feedback! We'd especially like to hear about performance differences after adopting
useSignalSelector(render counts and dispatch timings), any differences or issues with selector behaviors (including edge cases in proxy wrapper access), and bundle sizes.Changelog
useSignalSelectorandSignalProviderRedux is a simple event emitter, with O(n) subscriber behavior. Every dispatched action triggers a loop over all subscriber callbacks, which in turn normally call
getState()and a selector to determine if thatuseSelectororconnectneeds to re-render. React-Redux moves some of the tracking to be internal to itself and optimizes some subscription aspects, but it's fundamentally still calling O(n) callbacks every time. An app with 2,000 mounteduseSelectorhooks runs 2,000 selector functions per dispatch, then compares 2,000 results, even when the action touched one field. That work is usually cheap per selector but it scales linearly with app size and is the dominant per-dispatch cost in large apps.As with React, this works fine for most apps, but breaks down at large scales. I've talked with teams that had 20,000 connected components, and at that scale just running the callbacks themselves is a major perf issue.
Proxies allow tracking access to nested fields. Signals allow automatically deriving dependencies. More magic, but signals libs automatically only update the dependencies that actually rely on a given updated value.
We can't and won't change the semantics of Redux in terms of immutable updates, subscribing to the store, etc. But, we can leverage some of these techniques inside of React-Redux, invisibly to the end user.
useSignalSelectortracks which state paths each selector actually reads. Selectors run against a tracking proxy over the Redux state, which records property accesses likestate.todos[3].text. On each dispatch,SignalProviderdiffs the previous and next state trees and fires signals for the paths that changed. Only selectors that depend on a changed path re-run. Everything else is skipped entirely: no selector call, no equality check, no render.Internal implementation details
Internally this is a path-keyed signal graph built on the `alien-signals` propagation algorithm. Signals are created lazily as selectors touch paths and are released when the last subscriber unmounts, so memory tracks what is mounted rather than the size of the state tree. Dependency tracking is two-tiered: at mount, a hook records only which top-level state keys it reads and registers cheaply against those. It builds its full deep dependency graph the first time one of those keys changes. Hooks that never see a relevant dispatch never pay for the deep graph.You can adopt this incrementally.
SignalProvideris a drop-in replacement forProvider, anduseSignalSelectorhas the same signature and options asuseSelectorand passes the entire existinguseSelectortest suite, so you can switch individual components:Alternately, you can alias the whole app at once. The new
react-redux/signalsentry point exportsSignalProviderasProvideranduseSignalSelectorasuseSelector, so existing code and third-party libraries that import fromreact-reduxpick up the new implementation without edits:connect,useDispatch,useStore, and stockuseSelectorcontinue to work unchanged inside aSignalProvider. Reselect selectors work as expected, includingweakMapMemoize. Zombie-child and stale-props behavior matchesuseSelector. SSR produces identical markup.Performance
We benchmarked the branch against 9.3.0 across 16 scenarios in our react-redux-benchmarks suite, 10 seconds each, using React DevTools profiling builds so render counts are exact.
Total script time improved in 13 of 16 scenarios. Time spent inside
dispatch()dropped 49–88% in 12 of 16. Render counts matched 9.3.0 within a few percent everywhere except the scenarios where 9.3.0 was dropping frames under load, where signals renders more often because it keeps up. Some representative numbers:dispatch()The gains come from skipping work: in
derived-selectors, 9.3.0 re-runs every derived selector on each dispatch and renders 347 times; signals renders 30 times, and the extra renders in 9.3.0 were all wasted.There are costs. A tracked selector evaluation is 3–4x slower than a raw one because it runs through a proxy, so a selector that does re-run costs more than before. Each dispatch pays a diff of the previous and next state; this is proportional to how much of the tree actually changed, since unchanged subtrees are skipped by reference. Mount is a few milliseconds slower on small apps and faster on large component trees. The one scenario that regresses is a single component whose selector reads thousands of top-level state keys (+36% script), where there is no precision to recover and the diff is pure overhead.
The biggest tradeoff is bundle size. Opting in costs about 22.7 kB minified / 7.2 kB min+gzip more (32.9 kB / 10.9 kB total), which includes the
alien-signalsruntime. This is an intentional tradeoff - the extra logic and internal complexity requires more code. Stick with the standarduseSelectoras a default -useSignalSelectoris specifically meant for larger apps that will have a net benefit from its performance characteristics.The good news is that apps that keep using
ProvideranduseSelectorpay nothing for this feature. Stock React-Redux in a Vite 8 build is 10.2 kB minified / 3.7 kB min+gzip, unchanged from 9.3.0, and we have a CI check confirming the signals code tree-shakes out. Note that the rawdist/files grew a lot because the signals code ships in the same file; bundle-size bots that measuredist/will report a large increase that does not reflect what your app ships.Constraints and tradeoffs
Path tracking works by observing property reads and diffing plain data. That imposes rules that stock
useSelectordoes not:useSelectortolerates violations thatuseSignalSelectordoes not. If a reducer mutates an object in place and returns the same reference, the diff sees no change and dependent components will not update.Map,Set,Date, and class instances are tracked by reference only: replacing one triggers updates, mutating one does not.state.items.sort(),delete state.x,Object.assign(state.y, ...)) throws aTypeErrorwith a clear message. StockuseSelectorsilently allows this. Useslice().sort()ortoSorted().useSignalSelectorresults are the same references stockuseSelectorwould return and are safe to use asuseEffectdeps orMapkeys. If you pass state objects out of the selector by some other route,unwrap()converts them back to raw state.useSignalSelector(state => state)never updates. Returning the root state (or otherwise reading nothing specific from it) gives the tracker nothing to depend on. Select the fields you need.state.todos.map(t => t.text)depends on thetextcolumn across all todos, not on every property of every todo. Mapping and then reading deeper properties works, but may re-run more often than the minimal set.Details for each are on the
useSignalSelectorandSignalProviderAPI pages.Docs Restructure
The single "Hooks" API page has been split into separate pages for
useSelector,useDispatch, anduseStore, with the hooks overview page now serving as an index. New pages coverSignalProvider,useSignalSelector, andunwrap.What's Changed
Full Changelog: v9.3.0...v9.4.0-alpha.0
This discussion was created from the release v9.4.0-alpha.0.
All reactions