Zustand vs Legend-State vs Valtio: Proxy-Based State in 2026
·PkgPulse Team
TL;DR
Zustand remains the default choice for React state management — simple, performant, 3.2M weekly downloads. Legend-State is the best choice if you need fine-grained reactivity (only re-renders affected subscripted components) or offline-first sync (local-first apps with server persistence). Valtio is the most "magical" — proxy-based mutations with automatic subscriptions — best for developers who want MobX ergonomics without the class boilerplate.
Key Takeaways
- Zustand: 3.2M downloads/week, ~3KB, no boilerplate, React DevTools support
- Legend-State: 200K downloads/week growing fast, 0.25KB per observer, built-in persistence (Supabase, localStorage)
- Valtio: 700K downloads/week, proxy state,
useSnapshotauto-subscribes to accessed keys only - Performance: Legend-State's fine-grained reactivity is fastest for large lists and frequent updates
- For most SaaS apps: Zustand is sufficient and simpler
- For offline-first apps: Legend-State's sync plugins are uniquely powerful
Downloads
| Package | Weekly Downloads | Trend |
|---|---|---|
zustand | ~3.2M | ↑ Growing |
valtio | ~700K | → Stable |
legend-state | ~200K | ↑ Fast growing |
jotai | ~900K | → Stable |
mobx | ~2.1M | ↓ Declining |
Zustand: The Simple Standard
// npm install zustand
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface CartStore {
items: CartItem[];
total: number;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
total: 0,
addItem: (item) => set((state) => {
const items = [...state.items, item];
return { items, total: items.reduce((sum, i) => sum + i.price, 0) };
}),
removeItem: (id) => set((state) => {
const items = state.items.filter(i => i.id !== id);
return { items, total: items.reduce((sum, i) => sum + i.price, 0) };
}),
clearCart: () => set({ items: [], total: 0 }),
}),
{ name: 'cart-storage' } // Persists to localStorage
)
)
);
// Usage — component only re-renders when accessed slice changes:
function CartCount() {
const count = useCartStore(state => state.items.length); // Only subscribes to length
return <span>{count}</span>;
}
function CartTotal() {
const total = useCartStore(state => state.total); // Only subscribes to total
return <span>${total.toFixed(2)}</span>;
}
Legend-State: Fine-Grained Reactivity
// npm install @legendapp/state @legendapp/state/react
import { observable } from '@legendapp/state';
import { useSelector, observer } from '@legendapp/state/react';
import { syncedSupabase } from '@legendapp/state/sync-plugins/supabase';
// Observable state — fine-grained tracking:
const cartStore = observable({
items: [] as CartItem[],
get total() {
return this.items.reduce((sum, item) => sum + item.price.get() * item.qty.get(), 0);
},
});
// observer() HOC — component re-renders only for accessed observables:
const CartItem = observer(function CartItem({ item }: { item: Observable<CartItem> }) {
return (
<div>
{/* Only re-renders when this item's name changes: */}
<span>{item.name.get()}</span>
<button onClick={() => item.qty.set(v => v + 1)}>+</button>
</div>
);
});
Legend-State's Killer Feature: Sync Plugins
// Automatic Supabase sync — local-first with server persistence:
import { syncedSupabase } from '@legendapp/state/sync-plugins/supabase';
import { configureSynced } from '@legendapp/state/sync';
const postsStore = observable(
syncedSupabase({
supabase,
collection: 'posts',
select: (from) => from.eq('user_id', userId),
// Real-time updates via Supabase realtime:
realtime: true,
// Optimistic updates — UI updates instantly, syncs in background:
optimistic: true,
// Offline support — persists to IndexedDB:
persist: { name: 'posts', plugin: ObservablePersistIndexedDB },
})
);
// Changes persist to Supabase automatically:
postsStore.push({ title: 'New Post', content: '...' });
// ^ Immediately visible in UI, syncs to Supabase in background
Valtio: Proxy Magic
// npm install valtio
import { proxy, useSnapshot } from 'valtio';
// Create state with plain object — mutations via proxy:
const cartState = proxy({
items: [] as CartItem[],
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
},
});
// Mutations are just assignments (proxy intercepts them):
function addToCart(item: CartItem) {
cartState.items.push(item); // Direct mutation — Valtio tracks it
}
function updateQty(id: string, qty: number) {
const item = cartState.items.find(i => i.id === id);
if (item) item.qty = qty; // Direct property set
}
// Component subscribes only to accessed keys:
function CartTotal() {
const snap = useSnapshot(cartState);
// Only re-renders when `total` or accessed `items` change:
return <span>${snap.total.toFixed(2)}</span>;
}
function CartCount() {
const snap = useSnapshot(cartState);
return <span>{snap.items.length}</span>;
}
Performance Comparison
For a list of 1,000 items updating frequently:
Zustand with naive selector:
→ Re-renders entire list component on any item change
→ Fix: use useShallow or multiple selectors
Valtio:
→ Re-renders only components that accessed the changed key
→ Automatic — no selector optimization needed
Legend-State:
→ Most granular — each observer() component re-renders independently
→ 0.25KB per observed component overhead
→ Best for very large, frequently-updating lists
Decision Guide
Choose Zustand if:
→ Standard React app with typical state needs
→ Team is familiar with it (vast ecosystem, tutorials)
→ Want persistence, devtools, middleware ecosystem
→ Prefer explicit selectors over magic
Choose Legend-State if:
→ Offline-first app with server sync (Supabase, etc.)
→ Large lists with frequent partial updates
→ Fine-grained reactivity needed
→ Local-first architecture
Choose Valtio if:
→ Want MobX-style mutations without classes
→ Prefer direct mutations over set() calls
→ Coming from Vue's reactivity model
→ Moderate complexity state
Compare Zustand, Legend-State, and Valtio download trends on PkgPulse.
See the live comparison
View zustand vs. legend state on PkgPulse →