Benchmarks
Microbenchmarks comparing react-state-custom with Zustand, Jotai and a plain React context, run with vitest bench in jsdom. They measure the cost of the library's wiring, not of a real browser paint, so read the ratios, not the absolute milliseconds. The same scenarios also run in headless Chrome, with style and layout included, plus a nested-state scenario: see In a browser.
yarn bench # jsdom
yarn bench:browser # headless Chrome, about 10 minutesSetup
- 1000 consumer components. Updates are made from outside React (
storeRef().get().set(),store.setState(),store.set(atom), or the provider'ssetStatefor the context baseline) and wrapped inact(), so one iteration is one update plus every re-render and recomputation it causes. - No StrictMode, React 19.2, jsdom 27, Node 24, Apple M4 Pro in high-power mode. A number is the median of three runs, each the mean over 200 iterations (20 for mount); most cases varied by less than 10% between runs.
- Each library gets its idiomatic selective subscription: the
useStore()proxy,useStore(store, selector),useAtomValue(atom),useContext+useMemo. Adapters:adapters.tsx(flat),derived.tsx,topology.tsx,shop.tsx,collection.tsx. - "Tokens" is the code size of each adapter as the report measures it: TypeScript tokens of the adapter's source (whitespace and comments excluded, so independent of how lines are wrapped), including the bench-only plumbing (render counters, the out-of-React
update, a unique store name per run).
Five scenarios:
- flat: one store with 10 numeric keys, 100 consumers per key. Change one key.
- derived: a base store with 10 keys and a summary (
sumof all 10) that every consumer reads. Change one key (sum changes), or move 1 from one key to another in a single update (sum unchanged). - topology: a root config store, 10 derived stores each reading its own threshold from the root, 100 consumers per derived store. Change one threshold, change every threshold, or change a root key no derived store reads.
- shop: a four-layer graph.
config { vat, discount, theme }anditems { [id]: { price, qty } }(100 items) feed 100 lines (price × qty × (1 − discount)), 10 checkouts (10 lines each,× (1 + vat)) and one summary. 500 consumers read a line, 300 a checkout, 200 the summary. Change one item's qty,vat,theme(nothing derived reads it) ordiscount.react-state-customappears twice. The first adapter has one store per layer, each returning a flat object (lineskeyed by item id,checkoutsby group); each store calls the hook of the layer below once and reads the keys it needs, so a layer computes all its values in one run. The second, store per line, has the shape of the Jotai graph: one store instance per line (lineRef({ id })) and per checkout; a checkout reads its 10 lines, and the summary its 10 checkouts, withuseMultipleStore. Zustand keeps the derived values in the store and recomputes them on every write (the usual pattern; computing them in selectors instead would run the whole chain in every consumer). Jotai gets one atom per config key and per item plus derived atoms per line, checkout and the summary. - collection: 200 numeric items, 5 consumers per item, one item changed.
react-state-customappears twice: items spread as top-level store keys (the recommended shape), and the whole array under one key.
Consumer renders and derive calls
Deterministic, independent of the machine. "Derive calls" counts how often the derived value was computed: the summary or mid store hook, the derived atom, the selector, or the useMemo factory.
| flat | consumer renders to mount | consumer renders per update | tokens |
|---|---|---|---|
| react-state-custom | 2000 | 100 | 208 |
| zustand | 1000 | 100 | 138 |
| jotai | 1000 | 100 | 147 |
| React context | 1000 | 1000 | 211 |
| derived (renders / derive calls) | change one key | move 1 between two keys | tokens |
|---|---|---|---|
| react-state-custom | 1000 / 1 | 0 / 1 | 227 |
| zustand | 1000 / 4000 | 0 / 1000 | 138 |
| jotai | 1000 / 1 | 0 / 1 | 158 |
| React context | 1000 / 1000 | 1000 / 1000 | 241 |
| topology (renders / derive calls) | one threshold | all thresholds | unrelated root key | tokens |
|---|---|---|---|---|
| react-state-custom | 100 / 10 | 1000 / 10 | 0 / 0 | 247 |
| zustand | 100 / 1300 | 1000 / 4000 | 0 / 1000 | 165 |
| jotai | 100 / 10 | 1000 / 10 | 0 / 10 | 185 |
| React context | 1000 / 1000 | 1000 / 1000 | 1000 / 0 | 231 |
| shop (renders / derive calls) | qty of one item | vat | theme | discount | tokens |
|---|---|---|---|---|---|
| react-state-custom | 235 / 111 | 500 / 11 | 0 / 0 | 1000 / 111 | 556 |
| react-state-custom, store per line | 235 / 3 | 500 / 11 | 0 / 0 | 1000 / 111 | 576 |
| zustand | 235 / 111 | 500 / 111 | 0 / 111 | 1000 / 111 | 307 |
| jotai | 235 / 3 | 500 / 11 | 0 / 0 | 1000 / 111 | 470 |
| React context | 1000 / 111 | 1000 / 111 | 1000 / 111 | 1000 / 111 | 361 |
| collection | consumer renders per update | tokens |
|---|---|---|
| react-state-custom | 5 | 202 |
| react-state-custom, array in one key | 1000 | 205 |
| zustand | 5 | 163 |
| jotai | 5 | 148 |
| React context | 1000 | 206 |
react-state-custom renders each consumer twice on mount: once to ask for the store, once when the store hook has published (see tests/render-count.test.tsx). Stores are lazy by design: a derived layer defaults with ?? or ?. while the layer below has not published yet. Per update, selective subscriptions re-render only the consumers that read the changed value, like Zustand and Jotai; a plain context re-renders all of them.
Derived values are computed once per update here and in Jotai, because the derivation lives in a store (or atom) that consumers subscribe to. In Zustand the idiomatic place for a derivation is the selector, which runs in every consumer on every store change (useSyncExternalStore calls it several times per consumer per update: 4000 calls for 1000 consumers). Moving the derivation into the store avoids that but has to be done by hand on every write.
The "unrelated root key" column shows what the proxy buys inside stores: a derived store that only read thresholds is not re-run when label changes, so nothing downstream happens at all. Jotai recomputes the 10 derived atoms (they depend on the whole root atom; splitting the root into one atom per key would avoid it) and Zustand runs 1000 selectors; both then stop because the values are unchanged.
The shop graph shows the same thing at depth. With a store per layer, a store re-runs only when a key it read changes: vat skips the 100 lines (11 derive calls), theme does nothing. But the lines store reads every item, so one qty change recomputes all 100 lines and publishes only the one that changed (111 derive calls, 235 renders): granularity is per key at the output and per store at the computation. Jotai gets to 3 derive calls because each line is its own atom. The store-per-line adapter has the same granularity, one instance per line and per checkout read with useMultipleStore, and the same 3 derive calls. Zustand's precomputed derivations recompute all 111 values on every write, theme included.
The collection table is the limitation and its remedy side by side: the proxy tracks top-level keys, so an array under one key re-renders every reader on any change, while the same items spread as keys re-render only the five readers of the changed item, like a Zustand selector or a Jotai atom per item.
Time per operation (ms)
| scenario | react-state-custom | zustand | jotai | React context |
|---|---|---|---|---|
| flat: update 1 key, 100 of 1000 consumers affected | 0.56 | 0.51 | 0.38 | 1.59 |
| flat: update 1 key, all 1000 consumers affected | 3.55 | 2.14 | 2.24 | 1.72 |
| flat: mount + unmount 1000 consumers | 19.3 | 9.0 | 9.3 | 9.3 |
| derived: change one key (sum changes) | 3.53 | 4.35 | 2.02 | 3.19 |
| derived: move 1 between two keys (sum unchanged) | 0.027 | 0.62 | 0.002 | 2.89 |
| topology: one threshold (100 affected) | 0.63 | 0.47 | 0.39 | 1.68 |
| topology: all thresholds (1000 affected) | 3.51 | 1.98 | 2.29 | 1.83 |
| topology: unrelated root key (none affected) | 0.014 | 0.022 | 0.010 | 1.59 |
| shop: qty of one item (235 affected) | 1.34 | 0.66 | 0.63 | 1.72 |
| shop: qty of one item, store per line | 1.50 | |||
| shop: vat (500 affected) | 1.89 | 1.10 | 1.17 | 1.74 |
| shop: vat, store per line | 2.22 | |||
| shop: theme (none affected) | 0.017 | 0.038 | 0.0007 | 1.63 |
| shop: theme, store per line | 0.023 | |||
| shop: discount (1000 affected) | 3.95 | 2.20 | 2.46 | 1.80 |
| shop: discount, store per line | 4.70 | |||
| collection: one of 200 items, 5 of 1000 consumers affected | 0.26 | 0.26 | 0.20 | 1.54 |
| collection: same, array under one key (1000 affected) | 3.23 |
Reading the numbers
- Updates that re-render consumers cost 1.3–2.1x Jotai and up to 2.0x Zustand (1.1–1.3x Jotai in Chrome, see In a browser). A store in
react-state-customis a hook running in a headless component, so every update is two React commits: the store component re-renders and publishes from a layout effect, then the subscribed consumers re-render. Zustand and Jotai update a plain object and go straight to the consumers'useSyncExternalStore. That is the price of writing stores as ordinary hooks (effects, other hooks, other stores) with automatic mounting and teardown. Mount costs about 2x for the same reason: the second render per consumer above. - When a derivation is involved we are faster than Zustand (3.53 vs 4.35 ms) because its selectors recompute in every consumer, and when the derived value does not change 23x faster (0.027 vs 0.62 ms): one store re-render instead of 1000 selector runs. Jotai, whose atom graph is built for exactly this, is fastest in both.
- Every derived layer is one more React commit. In the shop graph a qty change travels items → lines → checkouts → summary → consumers, and each store on the way renders and publishes in its own commit, so the gap to Zustand and Jotai grows to about 2.1x (1.34 vs 0.66 and 0.63 ms). Zustand and Jotai propagate through plain objects and commit once.
- A store per line saves derive calls, not time. For a qty change it makes 3 derive calls instead of 111 and takes about as long: 1.50 against 1.34 ms, and 0.90 against 0.94 ms in Chrome. A line is one multiplication, while every instance that re-runs is a component render and a publish, and a change that reaches every line re-runs 100 of them: 4.70 against 3.95 ms for
discount(2.71 against 2.63 in Chrome). One instance per item is for items that carry work of their own, a fetch, a subscription or a heavy computation, which this scenario does not have. - Updates nothing reads are free in all three subscription libraries (under 0.05 ms); only the context baseline re-renders its thousand consumers.
- The context baseline wins when every consumer is affected anyway: one provider
setStatere-renders the subtree in a single pass, while subscription-based libraries schedule a thousand individual updates. It loses by 3–8x when 5 or 100 of the 1000 consumers care. - Code size favours Zustand and Jotai in every scenario, by roughly 1.5x.
createStore(() => init)plus a one-line selector, or anatomper value, is hard to beat; our layer iscreateStore(name, hook)withuseStateinside, and a write from outside React isstoreRef().get().action()instead ofstore.setState(). Some of the difference is bench plumbing (a unique name per run, typed casts), but the shape is real: the hook form costs more words per layer. None of them are library ceremony, though: there is no store object, selector, atom or provider to write, onlycreateStoreand the hook it returns; the extra tokens are theuseStateand the??defaults inside the hook itself. What the hook form buys is not brevity but that each layer can hold effects, async work and other hooks, which these scenarios do not exercise. - jsdom does no layout or paint, so these numbers are the libraries' own work. In a page, the DOM work of the components an update re-renders comes on top, and is the same in every library for the same re-renders. Choose on ergonomics.
Scale
bench/scale.bench.tsx runs react-state-custom alone, at sizes where the cost grows with the number of keys of one store or the number of instances: a keyed collection of 4000 items where one update changes every item, and 5000 parameterized instances starting at once. jsdom without StrictMode, as above, on the same laptop; mean ms over 50 updates (20 for the mount rows). 1.5.1 is the published version, measured from a worktree of its commit.
| scenario | 1.5.1 | current |
|---|---|---|
4000 keys change: a component listing them (Object.keys) | 900 | 2.5 |
| 4000 keys change: a component reading every key | 3.6 | 3.4 |
4000 keys change: one storeRef().subscribe listener | 2581 | 3.0 |
| a component reading all 4000 keys of a running store: mount + unmount | 199 | 1.5 |
| 5000 instances: mount + unmount | 626 | 439 |
- A store update notifies each subscription once per changed key. Up to 1.5.1, the listing component re-listed every key on each of the 4000 notifications, and the handle copied the whole state for each call, so both were quadratic. Readers now do that work once per update. What remains is the store rendering and publishing 4000 keys.
- A component reading every key compares the keys it read up to the first changed one, so an update that changes them all was already cheap. One that changes only the last 2000 was quadratic too: ~48 ms in 1.5.1, ~1.9 ms now (median of 5 runs, measured separately).
- Mounting a component that reads many keys holding values subscribed to each key, and every subscription reported its value at once, triggering a full check per key.
- Each instance captured a JavaScript stack when it started, about a third of its start cost.
In a browser
yarn bench:browser runs the scenarios above in headless Chrome with the same adapters, and adds a nested-state scenario (bench/browser).
- A production build without StrictMode. A sample is one update inside
flushSync, every render and commit it causes included, followed by a forced layout read: style and layout count, paint does not. The page gets a frame every 10 updates, outside the samples. - Three rounds in a shuffled order; per case and round, 20 warm-up and 100 timed updates (10 and 40 in the nested scenario). The tables show the median of all samples of a case. Raw samples, versions and machine:
results.json. - After each run the page reads every consumer's value from the DOM. In the scenarios above every library must show what the others show; in the nested scenario every view must show its reference value. All runs passed.
- Headless Chrome 154, Apple M4 Pro in high-power mode, macOS; React 19.2.0, Zustand 5.0.15, Jotai 3.0.1.
Scenarios (median ms per update)
| scenario | react-state-custom | zustand | jotai | React context |
|---|---|---|---|---|
| flat: update 1 key, 100 of 1000 consumers affected | 0.65 | 0.58 | 0.57 | 0.66 |
| flat: update 1 key, all 1000 consumers affected | 1.86 | 1.61 | 1.50 | 1.43 |
| derived: change one key (sum changes) | 1.81 | 2.75 | 1.60 | 2.25 |
| derived: move 1 between two keys (sum unchanged) | 0.070 | 0.59 | 0.015 | 1.04 |
| topology: one threshold (100 affected) | 0.65 | 0.55 | 0.57 | 0.64 |
| topology: all thresholds (1000 affected) | 1.92 | 1.53 | 1.56 | 1.40 |
| topology: unrelated root key (none affected) | 0.035 | 0.12 | 0.035 | 0.34 |
| shop: qty of one item (235 affected) | 0.94 | 0.83 | 0.76 | 0.77 |
| shop: qty of one item, store per line | 0.90 | |||
| shop: vat (500 affected) | 1.45 | 1.19 | 1.09 | 0.97 |
| shop: vat, store per line | 1.48 | |||
| shop: theme (none affected) | 0.033 | 0.14 | 0.005 | 0.36 |
| shop: theme, store per line | 0.040 | |||
| shop: discount (1000 affected) | 2.63 | 1.97 | 2.19 | 1.96 |
| shop: discount, store per line | 2.71 | |||
| collection: one of 200 items, 5 of 1000 consumers affected | 0.48 | 0.53 | 0.45 | 0.62 |
| collection: same, array under one key | 0.94 |
- Updates that re-render consumers cost 1.1–1.3x Jotai, against 1.3–2.1x in jsdom. Each sample now includes the style and layout of the changed elements, which is the same work in every library when the same consumers re-render, so the two commits of a store are a smaller share of it.
- A derived value that changes costs less than in Zustand (1.81 vs 2.75 ms), as in jsdom: one store run instead of a selector in each of the 1000 consumers.
- The other rows read as in jsdom: updates nothing reads cost almost nothing in the three subscription libraries, and the context baseline is fastest when all 1000 consumers re-render, except where each of them computes the derived value (derived sum).
Nested state
The case of Flatten a nested source at stress size: 3 stores of 300 numeric fields, and 5000 views that each read 10 fields picked by a fixed generator across the stores and render one weighted sum in an <i>. Four updates: one field; ten fields spread over the three stores; one field while every view's output stays the same (views sum floor(value / 10000)); one field no view reads. Seven ways to hold and read the fields:
- flat root: each store returns its fields as top-level keys; views read them through the proxy.
- nested root: each store returns
{ fields }; views readfieldsthrough the proxy. - nested root, flattened by a store: the nested stores, plus one store per root that returns
{ ...fields }; views read its keys. - nested root, selectors (react-state-custom and Zustand): one selector per view and store, summing the view's fields in that store.
- jotai: nested root, derived field atoms: a read-only atom per field derived from the root atom, and an atom per view summing its fields.
- jotai: one atom per field: a writable atom per field, no root object, and an atom per view.
Median ms per update:
| one-field | ten-fields | output-unchanged | unread-field | |
|---|---|---|---|---|
| react-state-custom: flat root | 1.95 | 4.43 | 0.58 | 0.060 |
| react-state-custom: nested root | 23.0 | 28.5 | 20.2 | 20.6 |
| react-state-custom: nested root, flattened by a store | 1.96 | 4.81 | 0.59 | 0.075 |
| react-state-custom: nested root, selectors | 3.76 | 10.2 | 1.80 | 1.71 |
| zustand: nested root, selectors | 3.57 | 9.18 | 1.57 | 1.60 |
| jotai: nested root, derived field atoms | 4.54 | 8.57 | 2.22 | 2.13 |
| jotai: one atom per field | 1.97 | 4.14 | 0.16 | 0.000 |
View renders per update:
| one-field | ten-fields | output-unchanged | unread-field | |
|---|---|---|---|---|
| react-state-custom: flat root | 48 | 531 | 48 | 0 |
| react-state-custom: nested root | 4920 | 5000 | 4920 | 4920 |
| react-state-custom: nested root, flattened by a store | 48 | 531 | 48 | 0 |
| react-state-custom: nested root, selectors | 48 | 531 | 0 | 0 |
| zustand: nested root, selectors | 48 | 531 | 0 | 0 |
| jotai: nested root, derived field atoms | 48 | 531 | 0 | 0 |
| jotai: one atom per field | 48 | 531 | 0 | 0 |
- Fields under one key re-render every view of the store: 4920 renders and 20–29 ms per update, whatever changed.
- Flattening the nested store matches the flat root: the same renders and the same time (1.96 vs 1.95 ms for one field), for one extra store run per update.
- Selectors render the same views, but run on every update: about 5000 selector calls, 3.6–3.8 ms for one field. When the outputs stay the same they render nothing, where the flat stores re-render the 48 readers of the changed field; with views this light, those renders (0.6 ms) still cost less than the selector calls (1.6–1.8 ms).
- Jotai with one atom per field is the fastest in every case but one field, where the flat root is level with it (1.95 vs 1.97 ms): a view depends on its own 10 atoms, and a write touches only the atoms it changes.
- It is a stress workload: many views, picks with no relation to each other, and a view of one element. Heavier views are not measured here; they make each render cost more, which favours the approaches that render fewer views.