Zustand vs Valtio: Proxy State vs Vanilla Store in React
Zustand and Valtio solve React state differently. Here's a no-fluff comparison of proxy state vs vanilla store with real code and honest tradeoffs.
Why This Comparison Actually Matters
Both Zustand and Valtio come from the same author — Daishi Kato — which is already a weird situation. You'd think one would obviously replace the other. But they don't. They solve different ergonomic problems, and picking the wrong one for your project will cost you in refactors down the road.
Zustand (introduced around 2019) went with a minimal, explicit subscription model. You create a store, you select from it, you mutate it via actions. Very Redux-brained, but without all the boilerplate. Valtio, which came later in 2021, went the opposite direction — wrap your object in a proxy(), mutate it directly like plain JS, and React re-renders automatically.
Honestly, if you've ever used MobX, Valtio will feel immediately familiar. If you've used Zustand for a year, switching to Valtio feels slightly uncanny because there are no selectors, no set() calls, just... mutation. Both are around 3 KB gzipped. Bundle size isn't your deciding factor here.
What IS your deciding factor: whether your team prefers explicit subscriptions or automatic dependency tracking. That philosophical split shapes everything — how you structure stores, how you test them, how you debug.
How Zustand Works: The Explicit Store
Zustand stores are functions. You call create(), pass a factory that receives set and get, and return your initial state plus any actions. Components subscribe by calling the hook with a selector. Re-renders only happen when the selected slice changes. That's it.
import { create } from 'zustand'
interface CartStore {
items: string[]
count: number
addItem: (item: string) => void
clear: () => void
}
const useCart = create<CartStore>((set, get) => ({
items: [],
count: 0,
addItem: (item) =>
set((state) => ({
items: [...state.items, item],
count: state.count + 1,
})),
clear: () => set({ items: [], count: 0 }),
}))
// In a component:
const count = useCart((s) => s.count) // only re-renders on count changeThe selector pattern (s => s.count) is the secret sauce. Without it you'd get the whole store object back, which forces re-renders on every state mutation. With it, you get surgical precision. Worth noting: if you forget the selector and just do useCart(), you'll get silent performance regressions in complex stores.
Zustand also has first-class middleware. immer, devtools, persist — all chainable. The persist middleware is one line and it works well with both localStorage and custom storage adapters. For anything UI-driven with complex async flows, that middleware ecosystem is genuinely helpful.
One more thing — Zustand works completely outside React too. You can call useCart.getState() and useCart.setState() from anywhere. No hooks, no context. That's huge for integrating with non-React code like WebSocket handlers or service workers.
How Valtio Works: The Proxy Store
Valtio's model is almost aggressively simple. You hand it a plain object, it wraps it in a Proxy under the hood, and any mutation anywhere — including in deeply nested objects — is tracked. Components that read from useSnapshot() re-render only for the properties they actually accessed. No selectors needed.
import { proxy, useSnapshot } from 'valtio'
const cartState = proxy({
items: [] as string[],
count: 0,
})
// Mutations are plain JS — call from anywhere
export function addItem(item: string) {
cartState.items.push(item)
cartState.count++
}
export function clearCart() {
cartState.items = []
cartState.count = 0
}
// In a component:
function CartBadge() {
const snap = useSnapshot(cartState)
return <span>{snap.count}</span> // re-renders only when count changes
}That useSnapshot() call returns an immutable snapshot of the proxy for use inside render. You read from snap in your JSX, you write to cartState in your handlers. The split feels odd for about 30 minutes, then becomes very natural. In practice, you stop writing selectors entirely, which shaves real time off complex store authoring.
Valtio's derived state story is also worth highlighting. derive() lets you compute values that automatically update when dependencies change — a bit like computed in Vue. No manual memoization.
import { derive } from 'valtio/utils'
const derived = derive({
totalLabel: (get) => `${get(cartState).count} items in cart`,
})
// Use the same way:
const snap = useSnapshot(derived)
console.log(snap.totalLabel)Quick aside: Valtio's proxy tracking can surprise you with arrays. Pushing to a proxied array works fine, but reassigning the entire array (state.items = newArray) also works. Just don't try to spread directly into the proxy — always mutate or fully reassign.
Performance: Who Wins on Re-renders?
Both libraries are smarter than context + useReducer by default. But the mechanism differs, and that matters at scale. Zustand re-renders only when the selected value changes — but you control the selector, which means you control your own destiny. Valtio re-renders only when the accessed snapshot properties change — but the tracking is automatic, so you can accidentally read too much.
The 2025 Valtio benchmark situation is this: if you do const snap = useSnapshot(bigState) and then access snap.userProfile.name in one component and snap.notifications.unread in another, each component gets its own tracked dependency graph. You never re-render name when notifications changes. That's automatic and correct.
Zustand wins when your team is disciplined about selectors, and when you need to compute derived values from multiple stores. Combining two Zustand stores in one selector is straightforward. Combining two Valtio proxies in derive() works, but requires more thought.
In practice, for most apps under ~100K monthly active users, you won't notice a performance difference. Both are fast. Pick based on DX first, optimization second. Where performance actually bites is async — and that's where architecture matters more than which proxy library you chose.
That said, if you're building something like a rich interactive component library (say, a glassmorphism card builder with real-time preview state), Valtio's mutation model maps more naturally to imperative UI updates. You'd feel the difference there.
TypeScript Experience: A Real Difference
Zustand's TypeScript story in v4+ is clean. You define your interface once, pass it as a generic, and everything downstream is fully typed. Actions, getters, selectors — all inferred correctly. The only rough edge is middleware: immer + devtools + persist chained together used to produce unreadable types in 2023. It's significantly better now.
Valtio's types are good too, but with a catch. The useSnapshot() return type mirrors your proxy shape with readonly properties, which is correct — but derive() types can require explicit annotations if your derived function is complex. Not a dealbreaker, just something to know going in.
// Zustand — types flow naturally
const useStore = create<{ count: number; inc: () => void }>()(
(set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
})
)
// Valtio — snapshot is typed readonly automatically
const state = proxy({ count: 0 })
// snap.count is number (readonly in snapshot)
// state.count is number (mutable on proxy)For teams doing strict TypeScript (strict mode on, no any, the works), both libraries hold up. Zustand's explicit action pattern arguably makes it easier to enforce a no-mutation-outside-actions rule at the type level, which some teams care deeply about.
Look, if you're building something design-heavy — like swapping state between multiple UI themes in a gradient generator or similar tool — both will do the job. TypeScript comfort is about the patterns around state, not the library itself.
Testing: Which Is Easier to Work With?
This is the practical question most comparisons skip. Both stores can be tested outside React using plain JS. But the ergonomics differ. Zustand stores can be reset between tests by calling store.setState(initialState). Valtio proxies need to be reset by reassigning all properties — or you import fresh module state per test file, which requires Jest's jest.resetModules() or Vitest's equivalent.
// Zustand reset in beforeEach
beforeEach(() => {
useCart.setState({ items: [], count: 0 })
})
// Valtio reset in beforeEach
beforeEach(() => {
cartState.items = []
cartState.count = 0
})Both approaches work fine. Valtio's mutation-based reset is arguably more fragile if someone adds a new property and forgets to reset it. Zustand's setState with the full initial object is more explicit. Small thing, but in a large test suite it adds up.
One more thing — testing async actions in Zustand is natural because actions are just functions in the store closure. In Valtio, actions are standalone functions you export alongside the proxy. Either way you're just calling a function and awaiting it. No real difference there.
When to Pick Which
Pick Zustand when your team already thinks in Redux patterns, when you need strong middleware (especially persist for offline apps), when you want co-located actions with state, or when you're building a shared library where you can't control how consumers import. Zustand's hook-based API is also marginally easier to explain to junior developers.
Pick Valtio when you're comfortable with MobX-style mutation, when you want to reduce selector boilerplate in large forms or editors, when you're integrating with imperative third-party APIs (Canvas, WebGL, game loops), or when you just want less ceremony around simple state. You can literally pass cartState to a non-React function and mutate it — no store getter needed.
There's also a legitimate 'use both' pattern for large apps. Use Zustand for complex shared state with actions and middleware, use Valtio for localized, imperative, or highly nested state like form builders or canvas editors. The two don't conflict; you can have both in the same project without a second thought.
Whatever you pick, don't reach for either when React's built-in useState + useReducer + Context is sufficient. Library overhead — mental, bundle, maintenance — is real. Empire UI components ship state-agnostic, so you're free to wire up whichever fits. The right tool depends on the team, the data shape, and how fast you want to ship.
FAQ
Yes, they coexist fine. There's no conflict because they use completely separate module graphs. A common pattern is Zustand for persistent app state and Valtio for ephemeral UI or editor state.
No. Valtio depends on the React useSyncExternalStore hook under the hood, which requires client components. Mark any component using useSnapshot() with 'use client'. Zustand has the same limitation.
Zustand has official Redux DevTools integration via the devtools middleware — one wrapper and you get time-travel debugging. Valtio has proxyWithDevtools from valtio/utils, but the experience is less polished. Zustand wins here.
Mostly yes. Nested objects and arrays are automatically wrapped in nested proxies. Just avoid storing non-serializable values like class instances or Map/Set without the proxyMap/proxySet utilities from valtio/utils — those helpers exist exactly for that case.