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

pre-release3 hours ago

This alpha release adds an opt-in useSignalSelector hook and SignalProvider component that only re-run selectors whose state dependencies actually changed, plus a react-redux/signals entry point for adopting them app-wide, and restructures the hooks API docs into separate pages.

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

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

useSignalSelector and SignalProvider

Redux 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 that useSelector or connect needs 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 mounted useSelector hooks 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.

useSignalSelector tracks which state paths each selector actually reads. Selectors run against a tracking proxy over the Redux state, which records property accesses like state.todos[3].text. On each dispatch, SignalProvider diffs 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. SignalProvider is a drop-in replacement for Provider, and useSignalSelector has the same signature and options as useSelector and passes the entire existing useSelector test suite, so you can switch individual components:

import { SignalProvider, useSignalSelector } from 'react-redux'

function TodoText({ id }: { id: number }) {
  const text = useSignalSelector((state) => state.todos.byId[id].text)
  return <span>{text}</span>
}

Alternately, you can alias the whole app at once. The new react-redux/signals entry point exports SignalProvider as Provider and useSignalSelector as useSelector, so existing code and third-party libraries that import from react-redux pick up the new implementation without edits:

// vite.config.ts
export default defineConfig({
  resolve: {
    alias: { 'react-redux': 'react-redux/signals' },
  },
})

connect, useDispatch, useStore, and stock useSelector continue to work unchanged inside a SignalProvider. Reselect selectors work as expected, including weakMapMemoize. Zombie-child and stale-props behavior matches useSelector. 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:

Scenario Script Time in dispatch() Avg dispatch Renders
derived-selectors -87% -70% 1.47 → 0.44 ms 347 → 30
tree-view -55% -73% 9.1 → 2.3 ms
multi-selector-component -28% -88% 6.5 → 0.9 ms
realistic-slice-count -28% -82% 1.15 → 0.21 ms 768 → 768
many-components-many-slices -30% -49% 8.1 → 3.7 ms
price-ticker -19% -58% 24.3 → 6.2 ms 247 → 410
forms -20% -80% 0.92 → 0.18 ms

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-signals runtime. This is an intentional tradeoff - the extra logic and internal complexity requires more code. Stick with the standard useSelector as a default - useSignalSelector is specifically meant for larger apps that will have a net benefit from its performance characteristics.

The good news is that apps that keep using Provider and useSelector pay 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 raw dist/ files grew a lot because the signals code ships in the same file; bundle-size bots that measure dist/ 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 useSelector does not:

  • State must be plain, immutably-updated objects and arrays. This is already what Redux requires, but useSelector tolerates violations that useSignalSelector does 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.
  • Selectors must not mutate state. In development, writing to state inside a selector (state.items.sort(), delete state.x, Object.assign(state.y, ...)) throws a TypeError with a clear message. Stock useSelector silently allows this. Use slice().sort() or toSorted().
  • Selectors receive a proxy, not the raw state. Values you return that are objects are unwrapped automatically, so useSignalSelector results are the same references stock useSelector would return and are safe to use as useEffect deps or Map keys. 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.
  • Array iteration tracks one level deep. state.todos.map(t => t.text) depends on the text column 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 useSignalSelector and SignalProvider API pages.

Docs Restructure

The single "Hooks" API page has been split into separate pages for useSelector, useDispatch, and useStore, with the hooks overview page now serving as an index. New pages cover SignalProvider, useSignalSelector, and unwrap.

What's Changed

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

Don't miss a new react-redux release

NewReleases is sending notifications on new releases.