Atomic State: Jotai, Valtio and Recoil Compared
The Atomic State Concept: Fine-Grained Reactivity
Most React application performance problems don't come from having too much state โ they come from subscriptions that are too coarse. When you have a global store with 100 fields and each component subscribes to the entire store, any field change forces every subscriber to re-render. Selectors help, but they're a patch applied to a fundamentally monolithic data model.
Atomic state management inverts this model. State is decomposed into the smallest independent units possible โ atoms โ and each component subscribes only to the atoms it actually reads. When atom A changes, only the components that depend on A re-render. Components that depend only on B and C are unaffected. This is true fine-grained reactivity, more thorough in concept than the selector-based approach.
Three libraries implement this idea, each with a distinct philosophy:
- Jotai: Minimal atoms, derived atoms, API surface is tiny by design
- Valtio: Proxy-driven, mutate directly, no API ceremony required
- Recoil: Facebook-originated, formal atom/selector duality, experimental status
Jotai: The Minimal Atom Philosophy
Jotai borrows Recoil's atom concept and aggressively strips away everything that isn't essential: no RecoilRoot configuration complexity, no string keys to manage, no conceptual separation between atoms and selectors. An atom is just a value with built-in reactivity.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// atom() creates an independent reactive state unit
const countAtom = atom(0);
const textAtom = atom('hello');
const userAtom = atom<User | null>(null);
// Three hooks with precise semantic differences
function Counter() {
// useAtom: equivalent to useState โ returns [value, setter]
const [count, setCount] = useAtom(countAtom);
// useAtomValue: read-only subscription, re-renders only when atom changes
const text = useAtomValue(textAtom);
// useSetAtom: write-only โ this component NEVER re-renders due to state changes
const setUser = useSetAtom(userAtom);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
useSetAtom is Jotai's most underappreciated performance tool. If a component only needs to trigger state changes but never reads the state (a "clear cart" button, a "logout" handler), using useSetAtom ensures that component is completely immune to re-renders caused by state changes. This is the atom equivalent of subscribing to a stable function reference in Zustand.
Derived Atoms: Jotai's Most Powerful Feature
Derived atoms let you compute new state from other atoms while maintaining the fine-grained subscription model. The dependency tracking is automatic โ you don't declare dependencies, Jotai figures them out by observing which atoms you call get() on.
// Primitive atoms
const priceAtom = atom(100);
const quantityAtom = atom(1);
const discountPercentAtom = atom(0);
const selectedCurrencyAtom = atom<'USD' | 'EUR' | 'CNY'>('USD');
// Read-only derived atom: automatically tracks all dependencies
const subtotalAtom = atom(get => get(priceAtom) * get(quantityAtom));
const totalAtom = atom(get => {
const subtotal = get(subtotalAtom);
const discount = get(discountPercentAtom);
return subtotal * (1 - discount / 100);
});
// totalAtom only recomputes when priceAtom, quantityAtom, or discountPercentAtom changes
// Read-write derived atom: has both getter and setter
const exchangeRatesAtom = atom({ USD: 1, EUR: 0.92, CNY: 7.2 });
const priceInCurrencyAtom = atom(
// Getter: compute display price in selected currency
(get) => {
const usdPrice = get(priceAtom);
const currency = get(selectedCurrencyAtom);
const rates = get(exchangeRatesAtom);
return (usdPrice * rates[currency]).toFixed(2);
},
// Setter: accept a price in the selected currency, convert back to USD
(get, set, newPriceInCurrency: number) => {
const currency = get(selectedCurrencyAtom);
const rates = get(exchangeRatesAtom);
const usdPrice = newPriceInCurrency / rates[currency];
set(priceAtom, usdPrice);
}
);
This bidirectional derivation pattern โ where you can both read a computed value and write back through it โ is something Redux selectors cannot do. It models real-world data transformations more naturally.
Async Atoms and Suspense Integration
Jotai has first-class support for async atoms, integrating naturally with React Suspense:
// An async atom: the getter returns a Promise
const currentUserIdAtom = atom<string | null>(null);
const userProfileAtom = atom(async (get) => {
const userId = get(currentUserIdAtom);
if (!userId) return null;
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json() as Promise<User>;
});
// Component usage with Suspense
function UserProfile() {
// useAtomValue will throw the Promise when data isn't ready,
// which Suspense catches and shows the fallback
const user = useAtomValue(userProfileAtom);
if (!user) return <p>Not logged in</p>;
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorMessage />}>
<UserProfile />
</ErrorBoundary>
</Suspense>
);
}
Jotai Utilities: atomWithStorage and atomWithReducer
import { atomWithStorage, atomWithReducer } from 'jotai/utils';
// Automatically syncs with localStorage (or sessionStorage, IndexedDB, etc.)
const themeAtom = atomWithStorage<'light' | 'dark'>('app-theme', 'light');
// Reading themeAtom hydrates from localStorage; writing persists automatically
// Atom with Redux-style reducer (useful for complex state transitions)
type CountAction = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };
const countReducer = (prev: number, action: CountAction): number => {
switch (action.type) {
case 'increment': return prev + 1;
case 'decrement': return Math.max(0, prev - 1);
case 'reset': return 0;
}
};
const countAtomWithReducer = atomWithReducer(0, countReducer);
function Counter() {
const [count, dispatch] = useAtom(countAtomWithReducer);
return (
<>
<span>{count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</>
);
}
Valtio: Proxy-Driven Mutable State
Valtio asks a question that sounds almost provocative: if JavaScript already has perfect data structures (objects and arrays), why not just mutate them directly?
Valtio wraps your state in an ES6 Proxy that tracks reads (subscription) and writes (triggers). Your code looks exactly like plain JavaScript object manipulation. The Proxy maintains the reactive subscription graph invisibly.
import { proxy, useSnapshot } from 'valtio';
// Create a reactive store โ it's just an object
const store = proxy({
count: 0,
user: null as User | null,
cart: {
items: [] as CartItem[],
discount: 0,
},
});
// Mutate from anywhere โ the Proxy handles subscription notification
function addToCart(item: CartItem) {
store.cart.items.push(item); // Direct mutation, anywhere in your codebase
}
function applyDiscount(pct: number) {
store.cart.discount = pct;
}
// In React components: useSnapshot creates an immutable snapshot
function CartView() {
// snap is a frozen snapshot of the current state
// Valtio tracks which properties of snap were accessed during render
// Only re-renders when those specific properties change
const snap = useSnapshot(store);
return (
<div>
{/* Accessing snap.cart.items.length โ subscribed to items array length */}
<p>{snap.cart.items.length} items in cart</p>
{snap.cart.items.map(item => (
<div key={item.id}>
{item.name} ร {item.quantity} = ${(item.price * item.quantity).toFixed(2)}
</div>
))}
<strong>Discount: {snap.cart.discount}%</strong>
</div>
);
}
The subscription granularity is at the property access level. If CartView only accesses snap.cart.items.length and snap.cart.discount, it will only re-render when those specific values change. Mutating store.user will not trigger a re-render in CartView.
Derived State with derive
import { derive } from 'valtio/utils';
import { subscribeKey } from 'valtio/utils';
// derive creates a read-only proxy with computed properties
const derived = derive({
cartTotal: (get) => {
const cart = get(store).cart;
const subtotal = cart.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return subtotal * (1 - cart.discount / 100);
},
itemCount: (get) => get(store).cart.items.length,
});
function CartSummary() {
const snap = useSnapshot(derived);
return (
<div>
<span>{snap.itemCount} items</span>
<span>Total: ${snap.cartTotal.toFixed(2)}</span>
</div>
);
}
// Subscribe to changes outside React (for analytics, side effects, etc.)
subscribeKey(store.cart, 'items', (items) => {
analytics.track('cart_updated', { itemCount: items.length });
});
Valtio's Tradeoff: Concurrent Mode Tension
Valtio's direct mutation model has a theoretical tearing risk under React Concurrent Mode. When React pauses and resumes rendering, it might read the Proxy state at different moments, potentially seeing intermediate mutation states. Valtio mitigates this through useSnapshot โ the snapshot is immutable for the duration of the render. However, Valtio does not use useSyncExternalStore, which is the React-blessed API for external state synchronization.
In practice, most applications won't encounter this tearing scenario. But for financial applications or any context where data consistency is a hard requirement, this theoretical risk is worth weighing.
Recoil: Facebook's Atom Experiment
Recoil, released by the Facebook team in 2020, is the most concept-rich of the atomic state libraries. It introduced a formal atom/selector duality and a comprehensive async state handling system built around Suspense.
import {
atom,
selector,
useRecoilState,
useRecoilValue,
useSetRecoilState,
RecoilRoot,
} from 'recoil';
// Atoms require unique string keys (used for persistence and DevTools)
const countAtom = atom({
key: 'counter/count',
default: 0,
});
const userIdAtom = atom<string | null>({
key: 'auth/userId',
default: null,
});
// selector: memoized derived computation, auto-tracks atom dependencies
const userProfileSelector = selector({
key: 'user/profile',
get: async ({ get }) => {
const userId = get(userIdAtom);
if (!userId) return null;
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
});
const doubleCountSelector = selector({
key: 'counter/double',
get: ({ get }) => get(countAtom) * 2,
});
function Counter() {
const [count, setCount] = useRecoilState(countAtom);
const double = useRecoilValue(doubleCountSelector);
return (
<div>
<p>Count: {count}, Double: {double}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
// RecoilRoot is required at the application root
function App() {
return (
<RecoilRoot>
<Counter />
</RecoilRoot>
);
}
An Honest Assessment of Recoil in 2024-2025
Recoil's development has effectively stalled. The latest stable version remains 0.7.x, key team members have left Meta, and the GitHub repository has accumulated hundreds of unresolved issues. React 19 compatibility requires --legacy-peer-deps to force installation, which is a significant warning sign for production use.
For new projects, Jotai is the clear successor โ it implements the same atom-first philosophy with a cleaner API (no string keys, no RecoilRoot requirement), active maintenance, and full React 19 compatibility.
Performance Comparison: Which Library Updates the Fewest Components
Test scenario: shared state containing { count: number, name: string, items: Item[] }. 50 components subscribe to different parts. Only count is updated.
| Library | Re-renders on count update |
Why |
|---|---|---|
| React Context | All 50 | Context broadcasts to all consumers |
| Zustand (no selector) | All 50 | Component subscribed to entire store |
| Zustand (with selector) | Only count subscribers |
Object.is comparison on selector output |
| Jotai | Only countAtom subscribers |
Atom-level isolation, automatic |
| Valtio | Only count-accessing components |
Proxy tracks property access automatically |
| Redux + RTK (no selector) | All 50 | useSelector runs every render |
| Redux + RTK (with selector) | Only count subscribers |
reselect memoization |
The key insight: atomic libraries (Jotai, Valtio) achieve fine-grained subscriptions with zero configuration; selector-based libraries (Zustand, Redux) require correct selector discipline to reach the same performance. If your team consistently writes precise selectors, the practical difference is small. If your team tends to skip selectors, atomic libraries provide a safer default.
Bundle Size Comparison
Bundle size matters for initial load time, particularly on mobile networks:
Zustand: ~8KB (minified + gzipped)
Jotai: ~13KB (core) / ~19KB (with utils)
Valtio: ~16KB
Redux Toolkit: ~47KB (+ react-redux ~18KB = ~65KB total)
Recoil: ~76KB โ significantly heavier
Zustand is the smallest option by a significant margin. For mobile-first applications or situations where bundle budget is tight, this matters. Recoil's size relative to its maintenance status makes it difficult to justify for new projects.
React 19 Compatibility
React 19's major additions โ the React Compiler (automatic memoization), the use() hook, and stable Server Components โ have compatibility implications for state libraries:
| Library | React 19 Status |
|---|---|
| Zustand | Full compatibility โ useSyncExternalStore is a React official API |
| Jotai v2+ | Full compatibility โ migrated to useSyncExternalStore in v2 |
| Valtio | Compatible, but concurrent mode tearing risk remains theoretical |
| Recoil | Requires --legacy-peer-deps; not recommended for new projects |
| Redux Toolkit | Full compatibility; enterprise support and long-term maintenance |
The React Compiler deserves a special note. It automatically memoizes component renders by analyzing dependencies at build time. This means many of the performance arguments for atomic state libraries (preventing unnecessary re-renders) become less relevant when the Compiler is enabled โ components that don't need to re-render simply won't, regardless of which state library you use. The Compiler doesn't eliminate the value of atomic libraries, but it does narrow the performance gap.
The Decision Tree: Choosing the Right Library
Choose Zustand when:
- Team size 2-8 developers
- Primarily a SPA with moderate state complexity
- Bundle size is a priority
- Want incremental adoption without a global refactor
- Team is comfortable with Hook-based patterns they already know
Choose Jotai when:
- You need fine-grained subscriptions without writing selectors manually
- The application has many small independent state units (form fields, UI toggles, feature flags)
- The team embraces atom-first thinking
- You want native Suspense integration for async state without extra plumbing
Choose Valtio when:
- You're mapping backend data models directly to state (the object shape matches)
- The team comes from a MobX or Vue background and prefers direct mutation
- You want the smallest possible API to learn
- Time-travel debugging is not a requirement
Choose Redux Toolkit when:
- Team size 8+ developers who need enforced conventions
- Time-travel debugging is a hard requirement (financial apps, complex wizards)
- Complex cross-domain state interactions that need centralized coordination
- RTK Query's caching model is a good fit for your data fetching patterns
- Project lifespan is 3+ years and long-term maintainability is the priority
Avoid Recoil for new projects: Maintenance stalled, React 19 compatibility issues, and Jotai provides a superior alternative with the same conceptual model.
The "Atom Explosion" Risk
Before choosing an atomic library, acknowledge the opposite problem from monolithic stores: atom sprawl. When every piece of state gets its own atom, applications with hundreds of atoms develop complex dependency graphs that are just as hard to reason about as a large Redux store โ and they have no equivalent of the Redux action log to help you trace what changed and why.
The discipline required for atomic state management is different from Redux, not necessarily less:
// This is manageable
const countAtom = atom(0);
const nameAtom = atom('');
const isOpenAtom = atom(false);
// This starts getting complex
const userPrefsAtom = atom<UserPrefs>(defaultPrefs);
const derivedA = atom(get => compute(get(userPrefsAtom)));
const derivedB = atom(get => compute2(get(derivedA), get(countAtom)));
const derivedC = atom(async get => {
const b = await get(derivedB);
return fetch(`/api?b=${b}`).then(r => r.json());
});
// Now you have an implicit dependency graph that no tool visualizes for you
Atomic state management is not simpler than Redux โ it's differently complex. Redux makes coordination explicit through actions. Atomic libraries make coordination implicit through the dependency graph. Choose the complexity model that matches how your team thinks about state.
The right state management choice is ultimately about which model produces the fewest surprises for your team over the life of the project. No library wins on every dimension. Understanding the tradeoffs โ not just the API surface โ is the difference between choosing a tool and actually being served by it.