Introduction
React's ecosystem has evolved significantly in how we manage application state. While Redux dominated for years, modern alternatives like Zustand, Jotai, and Valtio have emerged, each offering distinct approaches to solving the same fundamental problem: predictable, performant state management. These libraries challenge the traditional flux pattern with innovative paradigms—atomic state, proxy-based reactivity, and simplified stores. Understanding when and why to use each one is crucial for building efficient React applications in 2022 and beyond.
In this comprehensive guide, we'll dive deep into these three state management solutions, exploring their architectures, performance characteristics, and real-world use cases. You'll learn how each library handles reactivity under the hood, see practical implementation patterns, and gain the knowledge to choose the right tool for your next project. Whether you're building a simple todo app or a complex enterprise dashboard, understanding these modern state management approaches will level up your React development skills.
Understanding Modern React State: Core Concepts
The Evolution of State Management
React's built-in state management—useState, useContext, and useReducer—works well for many applications. However, as applications grow, developers encounter limitations: prop drilling through dozens of components, unnecessary re-renders cascading through the component tree, and the complexity of synchronizing state across distant parts of the UI. Traditional solutions like Redux introduced the flux pattern with actions, reducers, and a centralized store, but came with significant boilerplate and a steep learning curve.
The modern generation of state libraries takes a fundamentally different approach. Instead of enforcing a single pattern, they offer specialized solutions optimized for specific use cases. Zustand provides a minimal, hook-based store inspired by flux but without the ceremony. Jotai embraces atomic state inspired by Recoil, letting you compose state from independent atoms. Valtio uses JavaScript proxies to make any object reactive, enabling direct mutations that automatically trigger re-renders.
Core Principles
Each library is built on different foundational principles:
-
Zustand: A small, fast store that uses a simplified flux pattern. State lives in a single store, and components subscribe to specific slices using selectors, preventing unnecessary re-renders.
-
Jotai: Atomic state management where state is composed of independent "atoms." Components subscribe only to the atoms they use, enabling fine-grained reactivity without selector boilerplate.
-
Valtio: Proxy-based reactivity that wraps plain JavaScript objects. You mutate state directly, and the proxy intercepts changes to trigger re-renders only in components that accessed the modified properties.
Architecture and Design Patterns
Zustand Architecture
Zustand's architecture centers around a single store created with the create function. The store holds state and actions together, following a pattern similar to a simplified Redux without separate action creators and reducers.
import { create } from 'zustand'
interface BearState {
bears: number
increase: () => void
decrease: () => void
reset: () => void
}
const useBearStore = create<BearState>((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
decrease: () => set((state) => ({ bears: state.bears - 1 })),
reset: () => set({ bears: 0 }),
}))The set function merges updates into the existing state by default. Zustand uses structural sharing internally—when you update state, only the changed references trigger re-renders. Components subscribe using selectors:
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} bears around here</h1>
}
function Controls() {
const increase = useBearStore((state) => state.increase)
return <button onClick={increase}>Add bear</button>
}Jotai Architecture
Jotai's atomic model treats state as composable primitives. Each atom represents an independent piece of state, and derived atoms can depend on other atoms, forming a dependency graph.
import { atom, useAtom, useAtomValue } from 'jotai'
// Base atoms
const bearsAtom = atom(0)
const honeyAtom = atom(100)
// Derived atom (read-only by default)
const totalFoodAtom = atom((get) => {
const bears = get(bearsAtom)
const honey = get(honeyAtom)
return honey / Math.max(bears, 1) // honey per bear
})
// Writable derived atom
const feedingAtom = atom(
(get) => get(honeyAtom),
(get, set, consumed: number) => {
const current = get(honeyAtom)
set(honeyAtom, Math.max(0, current - consumed))
}
)Components select exactly the atoms they need:
function BearDisplay() {
const [bears, setBears] = useAtom(bearsAtom)
return (
<div>
<p>{bears} bears</p>
<button onClick={() => setBears(bears + 1)}>Add bear</button>
</div>
)
}
function FoodStatus() {
const foodPerBear = useAtomValue(totalFoodAtom)
return <p>{foodPerBear} honey per bear</p>
}Valtio Architecture
Valtio's proxy-based approach lets you work with plain JavaScript objects. The proxy function wraps an object, and useSnapshot creates a reactive snapshot for React components.
import { proxy, useSnapshot } from 'valtio'
const state = proxy({
bears: 0,
honey: 100,
nested: {
locations: ['forest', 'cave'],
},
get honeyPerBear() {
return this.bears > 0 ? this.honey / this.bears : 0
},
})
// Direct mutations - the proxy intercepts these
state.bears++
state.honey -= 10
state.nested.locations.push('mountain')In components, useSnapshot creates an immutable snapshot that only re-renders when accessed properties change:
function BearDashboard() {
const snap = useSnapshot(state)
return (
<div>
<p>Bears: {snap.bears}</p>
<p>Honey per bear: {snap.honeyPerBear}</p>
<button onClick={() => { state.bears++ }}>Add bear</button>
</div>
)
}Step-by-Step Implementation
Setting Up a Shared Shopping Cart
Let's implement the same feature—a shopping cart—using each library to demonstrate their different approaches.
Zustand Implementation
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
interface CartStore {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
total: () => number
}
const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
}
}
return { items: [...state.items, { ...item, quantity: 1 }] }
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) =>
i.id === id ? { ...i, quantity: Math.max(0, quantity) } : i
).filter((i) => i.quantity > 0),
})),
clearCart: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{ name: 'shopping-cart' }
)
)
// Usage in components
function ProductCard({ product }: { product: Product }) {
const addItem = useCartStore((state) => state.addItem)
return (
<button onClick={() => addItem(product)}>
Add to Cart — ${product.price}
</button>
)
}
function CartSummary() {
const items = useCartStore((state) => state.items)
const total = useCartStore((state) => state.total())
return (
<div>
{items.map((item) => (
<CartItem key={item.id} item={item} />
))}
<p>Total: ${total.toFixed(2)}</p>
</div>
)
}Jotai Implementation
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
// Persist cart in localStorage
const cartItemsAtom = atomWithStorage<CartItem[]>('shopping-cart', [])
// Derived atoms
const cartTotalAtom = atom((get) =>
get(cartItemsAtom).reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
)
const cartCountAtom = atom((get) =>
get(cartItemsAtom).reduce((sum, item) => sum + item.quantity, 0)
)
// Action atoms (writable derived atoms)
const addToCartAtom = atom(
null,
(get, set, product: Omit<CartItem, 'quantity'>) => {
const items = get(cartItemsAtom)
const existing = items.find((i) => i.id === product.id)
if (existing) {
set(
cartItemsAtom,
items.map((i) =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
)
)
} else {
set(cartItemsAtom, [...items, { ...product, quantity: 1 }])
}
}
)
const removeFromCartAtom = atom(
null,
(get, set, id: string) => {
set(
cartItemsAtom,
get(cartItemsAtom).filter((i) => i.id !== id)
)
}
)
// Usage in components
function ProductCard({ product }: { product: Product }) {
const addToCart = useSetAtom(addToCartAtom)
return (
<button onClick={() => addToCart(product)}>
Add to Cart — ${product.price}
</button>
)
}
function CartSummary() {
const items = useAtomValue(cartItemsAtom)
const total = useAtomValue(cartTotalAtom)
return (
<div>
{items.map((item) => (
<CartItem key={item.id} item={item} />
))}
<p>Total: ${total.toFixed(2)}</p>
</div>
)
}
function CartIcon() {
const count = useAtomValue(cartCountAtom)
return <span className="badge">{count}</span>
}Valtio Implementation
import { proxy, useSnapshot } from 'valtio'
import { derive } from 'valtio/utils'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
const cartState = proxy({
items: [] as CartItem[],
})
// Derived state using derive
const derived = derive({
total: (get) =>
get(cartState).items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
),
count: (get) =>
get(cartState).items.reduce((sum, item) => sum + item.quantity, 0),
})
// Actions are just functions that mutate the proxy
function addToCart(product: Omit<CartItem, 'quantity'>) {
const existing = cartState.items.find((i) => i.id === product.id)
if (existing) {
existing.quantity++
} else {
cartState.items.push({ ...product, quantity: 1 })
}
}
function removeFromCart(id: string) {
const index = cartState.items.findIndex((i) => i.id === id)
if (index !== -1) {
cartState.items.splice(index, 1)
}
}
function updateQuantity(id: string, quantity: number) {
const item = cartState.items.find((i) => i.id === id)
if (item) {
if (quantity <= 0) {
removeFromCart(id)
} else {
item.quantity = quantity
}
}
}
// Usage in components
function ProductCard({ product }: { product: Product }) {
return (
<button onClick={() => addToCart(product)}>
Add to Cart — ${product.price}
</button>
)
}
function CartSummary() {
const snap = useSnapshot(cartState)
const { total } = useSnapshot(derived)
return (
<div>
{snap.items.map((item) => (
<CartItem key={item.id} item={item} />
))}
<p>Total: ${total.toFixed(2)}</p>
</div>
)
}Real-World Use Cases
Use Case 1: E-Commerce Product Configuration
For an e-commerce site with complex product configurators, Zustand excels. Its middleware system handles persistence, devtools integration, and state synchronization seamlessly. The selector pattern prevents product thumbnails from re-rendering when only the cart badge changes.
const useProductConfig = create<ProductConfigStore>()(
devtools(
persist(
(set, get) => ({
selectedVariants: {},
setVariant: (productId, variantId, value) =>
set((state) => ({
selectedVariants: {
...state.selectedVariants,
[productId]: {
...state.selectedVariants[productId],
[variantId]: value,
},
},
})),
getConfiguration: (productId) =>
get().selectedVariants[productId] || {},
}),
{ name: 'product-config' }
)
)
)Use Case 2: Real-Time Dashboard
A real-time dashboard with dozens of independent metrics is ideal for Jotai. Each metric is an atom that updates independently, and derived atoms compute aggregations. Components subscribe only to their specific metrics, so updating one gauge doesn't re-render the entire dashboard.
const cpuAtom = atom(0)
const memoryAtom = atom(0)
const networkAtom = atom({ in: 0, out: 0 })
const healthScoreAtom = atom((get) => {
const cpu = get(cpuAtom)
const memory = get(memoryAtom)
return 100 - (cpu + memory) / 2
})
// WebSocket integration
function useMetricsWebSocket() {
const setCpu = useSetAtom(cpuAtom)
const setMemory = useSetAtom(memoryAtom)
const setNetwork = useSetAtom(networkAtom)
useEffect(() => {
const ws = new WebSocket('wss://metrics.example.com')
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
setCpu(data.cpu)
setMemory(data.memory)
setNetwork(data.network)
}
return () => ws.close()
}, [])
}Use Case 3: Collaborative Document Editor
For collaborative editing where multiple cursors, selections, and content changes happen simultaneously, Valtio's proxy-based approach shines. You can mutate nested document structures directly, and the proxy tracks exactly which parts of the document each component is watching.
const editorState = proxy({
document: {
content: '',
blocks: [] as Block[],
},
cursors: new Map<string, CursorPosition>(),
selections: new Map<string, Selection>(),
})
// Direct mutations for cursor updates
function updateCursor(userId: string, position: CursorPosition) {
editorState.cursors.set(userId, position)
}
// Deep nested mutations work naturally
function insertBlock(index: number, block: Block) {
editorState.document.blocks.splice(index, 0, block)
}Best Practices for Production
-
Use selectors consistently in Zustand: Always select the minimal state slice needed. Using
useStore(state => state)re-renders on every change. Instead, useuseStore(state => state.specificField). -
Leverage atom families in Jotai: For dynamic data like lists of items, use atom families to create atoms per item. This prevents the "one atom to rule them all" anti-pattern.
-
Avoid snapshotting large objects in Valtio: When using
useSnapshot, access only the properties you need in the render. The snapshot tracks property access at the proxy level. -
Persist state selectively: Not all state belongs in localStorage. Persist user preferences and cart data, but not transient UI state like modal open/close flags.
-
Combine with React Query for server state: Use Zustand/Jotai/Valtio for client state and React Query/SWR for server state. Don't cache API responses in your state management library.
-
Write unit tests for stores: Zustand and Jotai stores can be tested outside React. Create the store, call actions, and assert state changes without rendering components.
-
Use DevTools in development: Zustand and Valtio both support Redux DevTools, making state debugging significantly easier during development.
-
Keep actions close to state: Define actions alongside state definitions, not scattered across components. This makes state behavior predictable and testable.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Selecting entire store in Zustand | Components re-render on any state change | Use granular selectors: useStore(s => s.count) |
| Creating atoms inside components in Jotai | New atom on every render, losing state | Define atoms at module level or use useRef |
| Mutating snapshot in Valtio | Silent failures, no re-renders | Mutate the original proxy, read from snapshot |
| Over-globalizing state | Unnecessary coupling between features | Keep state local where possible, lift only when needed |
| Ignoring React concurrent mode | Tear in concurrent rendering | Use useSyncExternalStore pattern (built into these libraries) |
| Not handling async operations | Race conditions, stale state | Use middleware or action atoms for async flows |
Performance Optimization
Each library offers different performance characteristics. Here's how to optimize for each:
// Zustand: Use shallow comparison for object selectors
import { shallow } from 'zustand/shallow'
const { firstName, lastName } = useUserStore(
(state) => ({
firstName: state.firstName,
lastName: state.lastName,
}),
shallow
)
// Jotai: Split atoms to minimize re-renders
// Bad: One large atom
const allSettingsAtom = atom({ theme: 'dark', lang: 'en', fontSize: 14 })
// Good: Separate atoms
const themeAtom = atom('dark')
const langAtom = atom('en')
const fontSizeAtom = atom(14)
// Valtio: Use ref to avoid unnecessary proxy wrapping
import { ref } from 'valtio'
const state = proxy({
largeDataSet: ref(largeArray), // Won't be deeply proxied
})Benchmark Comparison (typical cold-start render, 1000 state updates):
| Library | Bundle Size | Update Speed | Memory Usage |
|---|---|---|---|
| Zustand | 1.1 KB | ~0.8ms | Low |
| Jotai | 2.4 KB | ~0.6ms | Medium |
| Valtio | 2.2 KB | ~1.0ms | Medium |
| Redux Toolkit | 11.7 KB | ~1.2ms | Higher |
Comparison with Alternatives
| Feature | Zustand | Jotai | Valtio | Redux Toolkit | Recoil |
|---|---|---|---|---|---|
| Bundle Size | 1.1 KB | 2.4 KB | 2.2 KB | 11.7 KB | 28 KB |
| Learning Curve | Low | Medium | Low | High | Medium |
| Boilerplate | Minimal | Minimal | None | Moderate | Low |
| TypeScript | Excellent | Excellent | Good | Excellent | Good |
| DevTools | Yes | Limited | Yes | Excellent | Limited |
| Middleware | Yes | Yes | Limited | Extensive | No |
| SSR Support | Good | Excellent | Good | Good | Experimental |
| React 18 Ready | Yes | Yes | Yes | Yes | Partial |
When to choose each:
- Zustand: You want a simple, predictable store with middleware support. Great for teams migrating from Redux.
- Jotai: You have highly independent state pieces that compose differently across views. Ideal for complex forms and dashboards.
- Valtio: You prefer mutable-style code and want minimal API surface. Excellent for prototyping and rapid development.
Advanced Patterns and Techniques
Cross-Store Communication with Zustand
// Subscribe to store outside React
useCartStore.subscribe(
(state) => state.items,
(items) => {
analyticsService.track('cart_updated', {
itemCount: items.length,
total: items.reduce((s, i) => s + i.price * i.quantity, 0),
})
}
)Async Atoms with Jotai
const userAtom = atom(async () => {
const response = await fetch('/api/user')
return response.json()
})
// In component - integrates with Suspense
function UserProfile() {
const user = useAtomValue(userAtom)
return <div>{user.name}</div>
}Computed Properties with Valtio
const state = proxy({
firstName: 'John',
lastName: 'Doe',
get fullName() {
return `${this.firstName} ${this.lastName}`
},
get initials() {
return `${this.firstName[0]}${this.lastName[0]}`
},
})Testing Strategies
Testing Zustand Stores
import { useCartStore } from './cart-store'
beforeEach(() => {
useCartStore.setState({ items: [] })
})
test('addItem adds new item to cart', () => {
const { addItem } = useCartStore.getState()
addItem({ id: '1', name: 'Widget', price: 9.99 })
expect(useCartStore.getState().items).toHaveLength(1)
expect(useCartStore.getState().items[0].quantity).toBe(1)
})
test('addItem increments quantity for existing item', () => {
const { addItem } = useCartStore.getState()
addItem({ id: '1', name: 'Widget', price: 9.99 })
addItem({ id: '1', name: 'Widget', price: 9.99 })
expect(useCartStore.getState().items[0].quantity).toBe(2)
})Testing Jotai Atoms
import { createStore } from 'jotai'
import { cartItemsAtom, cartTotalAtom } from './cart-atoms'
test('cartTotalAtom computes total correctly', () => {
const store = createStore()
store.set(cartItemsAtom, [
{ id: '1', name: 'A', price: 10, quantity: 2 },
{ id: '2', name: 'B', price: 5, quantity: 3 },
])
expect(store.get(cartTotalAtom)).toBe(35)
})Future Outlook
The React state management landscape continues to evolve. React Server Components are changing how we think about client-side state—much of what we store client-side may eventually live server-side. Signals, adopted by frameworks like Solid and Angular, are influencing React's direction through the React Compiler. Zustand, Jotai, and Valtio are all maintained by Poimandres (the same collective behind react-spring and react-three-fiber), ensuring they'll stay aligned with React's evolution.
The trend toward smaller, more focused libraries is accelerating. Developers are choosing composition over monoliths, combining multiple specialized tools rather than relying on a single state management solution. Understanding these modern approaches positions you well for whatever comes next in the React ecosystem.
Conclusion
Zustand, Jotai, and Valtio represent three distinct philosophies in modern React state management. Zustand offers a minimal flux pattern with excellent middleware support and a tiny footprint. Jotai brings atomic, composable state that eliminates selector boilerplate and enables fine-grained reactivity. Valtio makes state management feel natural through proxy-based reactivity with direct mutations.
Key takeaways:
- Zustand is your best choice for straightforward global state with a familiar flux pattern and the smallest bundle size.
- Jotai excels when your state is naturally decomposable into independent pieces that compose differently across views.
- Valtio is ideal when you want the most natural JavaScript experience with minimal API surface and direct mutations.
- All three are production-ready, TypeScript-friendly, and work with React 18's concurrent features.
- Consider combining these with React Query or SWR for server state management.
Start by evaluating your application's state complexity, your team's familiarity with different paradigms, and your performance requirements. Each library has its sweet spot, and choosing the right one will make your codebase more maintainable and your applications more responsive. Check out the official documentation for Zustand, Jotai, and Valtio to explore further.