Home / Articles / Measuring React State Managers: Render Counts and Bundle Cost Compared

This article is published in English.

Measuring React State Managers: Render Counts and Bundle Cost Compared

One cart app built with eight React state patterns, measured for wasted renders and gzipped bundle size, and what the numbers say about Zustand, Valtio and Context.

2227 words

The question of which React state manager to pick resurfaces constantly, and the debate usually runs on opinion rather than measurement. A more useful approach is to build the same small feature with every common pattern, hold behavior constant with a shared test suite, and count what actually differs: how often components re-render and how many kilobytes each option adds. This article walks through such an experiment with eight patterns, explains why the numbers come out the way they do, and gives you a repeatable recipe for running the same contest against your own state.

How the experiment is set up

The test app is deliberately tiny: a shopping cart with an apple button, a banana button, a running total and a theme badge whose value never changes. Every implementation is exercised by the same behavioral test, which clicks apple three times and banana twice and asserts identical results. While the test runs, a counter records each render of each component. Separately, esbuild measures what every pattern adds to a minified, gzipped bundle, with React excluded because every option pays that cost equally.

The eight contenders are:

  • lifted useState passed down through props
  • Context combined with useReducer
  • Redux Toolkit
  • Zustand
  • Jotai
  • Valtio
  • MobX
  • a hand-written store built on useSyncExternalStore, with no dependencies

All eight pass the shared contract, so any differences below are about cost, not correctness:

✓ lifted useState        › passes the shared cart contract
✓ Context + useReducer   › passes the shared cart contract
✓ Redux Toolkit          › passes the shared cart contract
✓ Zustand                › passes the shared cart contract
✓ Jotai                  › passes the shared cart contract
✓ Valtio                 › passes the shared cart contract
✓ MobX                   › passes the shared cart contract
✓ useSyncExternalStore   › passes the shared cart contract

Tests  8 passed (8)

The library versions and runtime used for the measurements are listed here, along with the repository that holds the eight implementations, the shared test and both measurement scripts:

Tested with react@19.2.8, zustand@5.0.15, @reduxjs/toolkit@2.12.0, react-redux@9.3.0,
jotai@2.20.2, valtio@2.3.2, mobx@7.0.3, mobx-react-lite@5.0.3, node 22.
Repo: github.com/noorjsdivs/state-patterns — 8 implementations, 1 shared test, 2 measurements.

Two choices that keep the comparison fair

Both of these come from bugs that show up in real applications, not from benchmarking etiquette.

First, each implementation creates its store inside a component rather than at module scope. That way every mounted app starts genuinely fresh, and no state leaks between test runs.

Second, the theme badge exists purely as an innocent bystander. It subscribes to a value that no click ever modifies, so any render it performs during the test is wasted work that can only be blamed on the state pattern.

What is deliberately out of scope

The contest covers shared client state only. TanStack Query is absent because it manages a cache of server data, which is a different problem with different rules. URL state is absent because the address bar belongs to the router. Your application likely needs both, but neither competes in this particular event.

Surprise one: the code size is nearly identical

Before the interesting numbers, a boring one that deserves attention. The eight implementations range from 39 to 58 lines. The most ceremonious option, Redux Toolkit at 58 lines, is just nineteen lines longer than the simplest possible approach, lifted state at 39. At this scale the boilerplate argument that dominates so many state-management discussions amounts to nineteen lines. The meaningful differences are elsewhere, and they only become visible with instrumentation.

The render scoreboard

Here is how many times each component rendered across the five clicks, including the initial mount:

5 clicks (3 apple, 2 banana)     Apple  Banana  Total  Theme(idle)

lifted useState                    6      6       6       6
Context + useReducer               6      6       6       6
Redux Toolkit                      4      3       6       1
Zustand                            4      3       6       1
MobX                               4      3       6       1
useSyncExternalStore               4      3       6       1
Jotai                              5      4       7       2
Valtio                             2      2       2       1

Three distinct stories emerge from these columns.

Lifted state and Context re-render everything

With lifted useState and with a single Context, every component renders on every click. The theme badge rendered six times even though its value never changed, and the banana button rendered on every apple click. This is the concrete mechanism behind the common warning that Context is not a state manager. useContext subscribes a component to the entire context value, so whenever that value changes identity, every consumer renders. Putting all your state in one context is effectively lifted state with an extra layer.

The usual remedy is to split state across several contexts, or to memoize consumers, and that was not measured here. The row reflects Context as it is most often written: one provider holding one value.

Four APIs, one identical result

Redux Toolkit, Zustand, MobX and the hand-written store produced exactly the same column: each component renders once on mount and again only when the slice it reads actually changes. Their APIs look nothing alike, yet their runtime behavior matches, because all four rely on the same underlying idea. Components listen to a selected piece of state rather than to the whole store, and they are notified only when that piece changes. For a closer look at how that selection and equality check works in one of these libraries, see how React Redux decides when to re-render.

Valtio's suspiciously low numbers

Valtio's column looks like a broken counter at first: two renders for a button clicked three times. The counts are correct. Valtio batches change notifications on the microtask queue, so several rapid, synchronous updates collapse into one re-render per component, and the final values are still right.

There is an important caveat. A person clicking at normal speed produces one render per click, because each click finishes before the next begins. The advantage only appears when updates arrive in bursts, as they do with WebSocket messages, streaming data or drag events. If your application has that profile, this column deserves close attention.

Jotai's consistent extra render

Jotai rendered each component one time more than the selector-based group, including two renders for the idle theme badge. Its granularity is correct, since components still only react to the atoms they use, and the extra render is uniform across every column. That pattern points to something in the initial mount sequence with the store Provider rather than a subscription leak, but the precise cause was not identified. Treat it as an open question rather than a verdict against Jotai.

The bundle-size weigh-in

This is what each pattern adds to a gzipped bundle, counting both the pattern's own code and its library, with React treated as external:

lifted useState          0.4 KB
useSyncExternalStore     0.5 KB     (zero dependencies)
Context + useReducer     0.5 KB
Zustand                  0.7 KB
Valtio                   2.7 KB
Jotai                    4.4 KB
Redux Toolkit           10.7 KB
MobX                    13.2 KB

The heaviest option is 33 times larger than the lightest. Put differently, Redux Toolkit and MobX together come to 23.9 KB, while the remaining six patterns combined total 9.2 KB.

The interesting part is how this list interacts with the render scoreboard. Only two patterns combine perfect render behavior with a footprint under one kilobyte: Zustand and the hand-written store. Redux Toolkit achieves the same render column at 10.7 KB, and MobX at 13.2 KB. That is not automatically a mark against them. It is a price, and a price only makes sense once you know what it buys.

Three practical picks and what each costs

For shared client state in a typical application, the measurements point to three tools worth reaching for.

Zustand as the default

Zustand delivers perfect render granularity at 0.7 KB in 54 lines, and its API needs almost no explanation for a new teammate: it is a hook that takes a selector. In this test it matched Redux's runtime behavior at roughly one fifteenth of the size.

That result depends on one discipline: always select a slice. A call like useCart(s => s) subscribes the component to the entire store, which reproduces the Context problem. The efficient column comes from writing narrow selectors, not from the library's name. If a selector returns a new object or array on each call, you also need a shallow equality helper, or every update will look like a change.

A hand-written useSyncExternalStore store

This option is the one the experiment makes most compelling. The complete implementation, store included, fits in 58 lines, adds 0.5 KB, matches Redux's render column exactly and brings no dependencies to audit or upgrade. useSyncExternalStore is the primitive React itself provides for subscribing to external stores safely, including under concurrent rendering. For library authors, or for teams that prefer minimal dependencies, it is sufficient. The trade-off is that you own the code: devtools, middleware and persistence are yours to build if you need them.

Valtio for bursty updates

Valtio's microtask batching was measurable, real and unique among these contenders, and 2.7 KB is a reasonable price for it. Writing a direct mutation like state.apples++ and seeing exactly the right components update feels almost too convenient, but the render counts confirm the behavior is genuine.

Why the others lose this contest without being bad

The shape of the test matters, and it favors small, flat state. The other options have strengths this cart cannot exercise:

  • Redux Toolkit's 10.7 KB pays for devtools, time-travel debugging, middleware and conventions that hold up across large organizations. With fifty developers on one codebase, those conventions are the real product.
  • MobX's observable model is strongest in class-heavy domain code, which this app does not have.
  • Jotai's atom graph shines when derived state becomes deep and interconnected, and a single derived total barely touches that.
  • Context remains the right tool for values that rarely change, such as theme, locale or session. Ironically, the theme column is exactly where it performed worst here, because the cart state shared its provider.
  • Lifted state is still correct for state that is not shared. It lost only because this contest is specifically about sharing.

A design rule that is not a matter of taste

Every implementation here created its store inside a component. A store defined at module level outlives unmounting, which is how a shopping cart survives a logout and greets the next person who signs in on the same device. It also causes state to bleed between tests and between requests during server rendering. If you take a single code review rule from this comparison, make it this one: scope stores to the component tree, typically by creating them in a provider, unless you intentionally want global lifetime.

Running the same contest on your own app

You can reproduce this measurement for your own state in an afternoon:

  1. Choose the most debated piece of shared state in your app and build a four-component harness around it: two components that write, one that reads a derived value and one idle bystander.
  2. Add a render counter to each component. A module-level object plus one track() call in each component body takes around ten lines. The bystander's count is your Context tax, measured rather than guessed.
  3. Measure each candidate with esbuild --bundle --minify, marking React as external. It takes seconds and adds a kilobyte column to the discussion.
  4. If you are on Context and the bystander re-renders, either split the context or move to a selector-based store, then rerun the counter and include the before-and-after numbers in your pull request.
  5. Repeat the whole exercise once React Compiler is part of your build, because automatic memoization may change the lifted-state row substantially.

Open questions

Two threads remain loose. The smaller one is the cause of Jotai's extra render. The larger one is React Compiler. The lifted-state row shows 6/6/6/6 precisely because nothing in it is memoized, and memoization is exactly what the compiler automates. Whether it brings that row in line with the selector-based stores on real production code, rather than on a demo, is the obvious next experiment.

Key takeaways

  • At small scale, boilerplate differences between state libraries are negligible; render behavior and bundle size are where they actually diverge.
  • A single Context holding changing state re-renders every consumer, including components that never read the changed value.
  • Selector-based stores, whether Redux Toolkit, Zustand, MobX or a hand-rolled useSyncExternalStore store, converge on the same efficient render pattern.
  • Zustand and a hand-written store give that behavior for under a kilobyte; heavier libraries earn their size through tooling and conventions, not rendering.
  • Valtio's batching pays off for bursty update streams rather than ordinary clicking.
  • Create stores inside the component tree so state cannot outlive the session that produced it.