Why React Compiler Structurally Favors Zustand, and the Current Compatibility Status of Jotai and Valtio
In October 2025, React Compiler 1.0 was finally released as stable. Coming from an era where we had to manually sprinkle useMemo, useCallback, and React.memo everywhere, this is quite a significant shift. My first thought when I heard the news was, "So what happens to the state management libraries I'm currently using?" I imagine many of you have the same question.
To get to the point: the situation varies considerably by library. Zustand is structurally well-compatible, Jotai works fine in most cases but requires care with derived atoms, and Valtio is safe with useSnapshot but runs into conflicts with the useProxy pattern. The effects have been validated in real production environments. Sanity Studio saw a 20–30% reduction in render time after applying React Compiler, and Wakelet confirmed a 10% improvement in LCP and 15% in INP. Now that performance gains are materializing in practice, how well your current state management library meshes with the compiler is no longer a theoretical question — it's a real-world concern. This article examines the internal structure of each library to understand why they behave the way they do, and walks through code examples to confirm which patterns are safe.
Core Concepts
What React Compiler Expects: Pure Functions and Immutable Inputs
React Compiler is a tool that analyzes code at build time and automatically inserts useMemo, useCallback, and React.memo. It operates as a Babel or SWC plugin, tracking the value dependencies of each expression and transforming code to reuse cached results whenever those dependencies haven't changed.
For this analysis to work correctly, however, there are prerequisites.
The compiler's core assumption: A component render must be a pure function that takes inputs — props and state — and returns UI. If a hook's returned value is mutated directly, or if side effects are mixed into the render path, the compiler decides "this can't be safely optimized" and quietly skips that component.
This is exactly where compatibility with state management libraries diverges. The compiler's affinity with a library is determined by how faithfully it upholds immutability and how cleanly it keeps the render path.
Automatic Detection of Incompatible Patterns: eslint-plugin-react-compiler
The eslint-plugin-react-compiler package includes an incompatible-library rule. When it detects API usage that the compiler cannot safely optimize, it automatically skips compilation for that component.
The important thing is that this happens silently. It's not an error — the optimization simply doesn't get applied. This is why, in the second half of 2025, quite a few issues appeared in the community asking "why isn't this compiling?" TanStack Table and certain React Hook Form patterns are typical examples, and Valtio's useProxy falls into this category as well.
With these principles in mind, let's examine how each library behaves with code examples.
Practical Application
Example 1: Zustand — Natural Compatibility Born from Its External Store Structure
The reason Zustand works well with React Compiler lies in its structure. The store exists outside the React render cycle, and components subscribe only to the slices they need via selectors.
import { create } from 'zustand'
interface CountState {
count: number
increment: () => void
}
// Store — state that lives outside React
const useStore = create<CountState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
// Component: subscribes only to the values it needs via a selector
function Counter() {
const count = useStore((s) => s.count)
const increment = useStore((s) => s.increment)
return <button onClick={increment}>{count}</button>
}Internally, useSyncExternalStoreWithSelector is at work. This is an API officially added in React 18 that allows safe subscription to external stores outside the React render cycle. Its key role is also to prevent tearing — the phenomenon where UI state becomes inconsistent across multiple renders — which can occur in Concurrent Mode.
When I first applied React Compiler to a team project, the Zustand store worked without any additional configuration. In hindsight, this was inevitable. When a change occurs in the store, a re-render is triggered only when the value of the selected slice changes, and the returned snapshot is an immutable object. From the compiler's perspective, it can verify through static analysis that there's no reason to recompute as long as the value count hasn't changed.
| Analysis Item | Zustand's Behavior | Compiler Compatibility |
|---|---|---|
| State location | External store outside React | ✅ Render path is isolated |
| Return value form | Immutable snapshot | ✅ Dependency tracking possible |
| Mutating hook return values | Not possible (only via set function) | ✅ Rules followed |
| Subscription method | useSyncExternalStoreWithSelector |
✅ Official React API |
Example 2: Jotai — Compatible by Default, but Be Careful with Derived Atoms
Jotai's basic atoms and useAtom work well with the compiler. Jotai v2 is internally organized around the concept of a store, which aligns naturally with React's render model.
import { atom, useAtomValue } from 'jotai'
const countAtom = atom(0)
// Safe derived atom — pure read function
const doubleAtom = atom((get) => get(countAtom) * 2)
function DoubleCounter() {
const double = useAtomValue(doubleAtom) // compiler-friendly
return <div>{double}</div>
}The problem arises when side effects are mixed into a derived atom's getter. If you drop a console.log inside the getter while debugging, as shown below, that alone makes it an impure function.
import { atom } from 'jotai'
// Caution — side effects inside the getter narrow the compiler's optimization scope
const debugAtom = atom((get) => {
console.log(get(countAtom)) // Contains side effect → compiler optimization restricted
return get(countAtom)
})Honestly, using this pattern intentionally in production is rare, but as complex derived atom chains accumulate, impure functions can creep in at some point. It's recommended to keep derived atom getters pure, and if side effects are needed, use a separate mechanism like atomEffect.
Jotai's author, Daishi Kato, has stated that his direction for Jotai in the React Compiler era is focused on expanding the server-side atom model. This reads as an intention to focus on expressing complex state dependencies rather than simple render optimization — a positioning that seems fitting for an era where the compiler absorbs straightforward optimizations.
Example 3: Valtio — The Difference Between useSnapshot and useProxy
Valtio provides two hooks whose compiler compatibility diverges completely.
import { proxy } from 'valtio'
import { useSnapshot } from 'valtio'
const myProxy = proxy({ count: 0 })
// ✅ Recommended — useSnapshot (returns immutable snapshot, compiler-friendly)
function SafeCounter() {
const snap = useSnapshot(myProxy)
return (
<div>
<span>{snap.count}</span>
{/* State changes go directly to the proxy — snap is read-only */}
<button onClick={() => myProxy.count++}>+1</button>
</div>
)
}import { proxy } from 'valtio'
import { useProxy } from 'valtio' // Legacy/experimental pattern
const myProxy = proxy({ count: 0 })
// ⚠️ Caution — useProxy (compiler can't detect mutation and skips optimization)
function UnsafeCounter() {
const state = useProxy(myProxy)
// Directly mutating the hook's return value → judged as a React rules violation
return <button onClick={() => state.count++}>{state.count}</button>
}useProxy is an experimental API and is not an official pattern on par with useSnapshot. Directly mutating the Proxy object returned by useProxy inside a component is, from the compiler's perspective, a "mutation of a hook's return value = rules violation." When it statically detects a signal that state might change during render, it gives up on optimizing that component. Valtio v2 improved the API design by reinforcing a useSnapshot-centric approach, but if useProxy patterns are mixed throughout your codebase, a review is warranted.
Pros and Cons Analysis
Here's a side-by-side comparison of all three libraries.
| Library | React Compiler Compatibility | Patterns to Watch | Recommended Use Case |
|---|---|---|---|
| Zustand | ✅ Structurally compatible | Overuse of inline selectors | Most global state management |
| Jotai | ✅ Compatible by default (watch derived atoms) | Impure getters | Expressing complex state dependencies |
| Valtio | ⚠️ Depends on pattern | Direct mutation via useProxy |
Limited to useSnapshot pattern |
Zustand's structure itself is compiler-friendly, so it works without any additional configuration. Jotai's atom model excels at expressing complex state dependencies, but care is needed to keep derived atom getters pure. Valtio's mutable style is intuitive and familiar to those with Vue or MobX experience, and as long as you use the useSnapshot pattern, it works with the compiler without issue.
The Most Common Mistakes in Practice
- Assuming the compiler is applied while using
useProxy— Because there's no error and the optimization is silently skipped, you may not know unless you check the build logs. Enabling theincompatible-libraryrule ineslint-plugin-react-compilerlets you detect these situations in advance. - Writing Zustand selectors as inline functions — Even with the compiler, patterns like
useStore((s) => s.count)that create a new function on every render can cause unnecessary re-subscriptions. It's recommended to declare selectors outside the component or use them together withuseShallow. - Putting debugging code inside Jotai derived atom getters — Even a single
console.logmakes it an impure function, which narrows the compiler's optimization scope. Keep derived atom getters pure, and use a separate mechanism likeatomEffectfor side effects.
Closing Thoughts
In the React Compiler era, the criteria for choosing a state management library ultimately converges on: "Does it structurally guarantee immutability?" Zustand satisfies that criterion most naturally, Jotai works well as long as derived atoms are kept pure, and Valtio can be used safely by switching to the useSnapshot pattern.
Here are 3 steps you can take right now.
- Install
eslint-plugin-react-compilerand enable the rule — Afterpnpm add -D eslint-plugin-react-compiler, add theincompatible-libraryrule to your ESLint config to detect incompatible patterns in advance. - If you're using Valtio, audit your
useProxyusage — Grep foruseProxycalls across your project (grep -r "useProxy" src/) and convert any patterns found to theuseSnapshot+ direct proxy mutation approach. - Audit your Zustand selector patterns — Extract inline selectors outside the component, or apply
useShallowto selectors that return objects. This will help you maximize the synergy with React Compiler.
References
- Thoughts on State Management Libraries in the React Compiler Era | Daishi Kato's blog
- React Compiler v1.0 | Official React Blog
- Introducing React Compiler | Official Docs
- incompatible-library Rule | Official React ESLint Plugin Docs
- useSyncExternalStore | Official React Docs
- Will React Compiler affect Zustand? | Zustand GitHub Discussion #2562
- How to mark valtio's useProxy | React GitHub Issue #31311
- Meta's React Compiler 1.0 Brings Automatic Memoization to Production | InfoQ
- useSnapshot | Valtio Official Docs
- React Compiler Deep Dive: Automatic Memoization | DEV Community