Skip to main content

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, useSnapshot auto-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

PackageWeekly DownloadsTrend
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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.