Skip to main content

Zustand vs Legend-State vs Valtio

·PkgPulse Team
0

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 Reliable Default

Zustand has earned its place as the default React state manager by doing one thing exceptionally well: getting out of your way. With roughly 10 million monthly downloads and a bundle under 3KB, it adds essentially zero weight to your application while providing everything a typical SaaS product needs — global state, persistence, DevTools, and middleware.

The core API is a single function: create(). You define a store as a function that receives set and get, return your initial state and actions, and you're done. There are no actions to dispatch, no reducers to write, no providers to wrap your tree in. You call the hook directly and access whatever slice you need.

The key to avoiding unnecessary re-renders with Zustand is selector functions. When you call useStore(state => state.items.length), your component only re-renders when that derived value changes — not when any other part of the store mutates. For complex objects where you need to subscribe to multiple fields at once, useShallow provides referential-equality comparison so your component doesn't re-render when the values are deeply equal.

// 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>;
}

Zustand also works completely outside of React. You can read or write state from event listeners, WebSocket callbacks, or server actions simply by calling useCartStore.getState() or useCartStore.setState(). This makes it useful for coordinating state across parts of your application that don't live in React components, like analytics handlers or background sync logic.

The middleware ecosystem is mature. persist handles localStorage or any storage adapter. devtools wires up Redux DevTools. immer lets you write mutations in a draft-based style. subscribeWithSelector gives you fine-grained subscriptions outside React. Between these, virtually every state management pattern is covered without leaving the Zustand API.

Zustand's Auth Store Pattern

A common pattern that Zustand handles elegantly is authentication state combined with async loading:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthStore {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  refreshToken: () => Promise<void>;
}

export const useAuthStore = create<AuthStore>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      isLoading: false,

      login: async (email, password) => {
        set({ isLoading: true });
        try {
          const { user, token } = await api.auth.login({ email, password });
          set({ user, token, isLoading: false });
        } catch (error) {
          set({ isLoading: false });
          throw error;
        }
      },

      logout: () => set({ user: null, token: null }),

      refreshToken: async () => {
        const { token } = get();
        if (!token) return;
        const refreshed = await api.auth.refresh(token);
        set({ token: refreshed.token });
      },
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({ token: state.token }), // Only persist token
    }
  )
);

Legend-State: Fine-Grained Reactivity

Legend-State takes a fundamentally different approach than Zustand. Instead of managing subscriptions through selector functions, it makes individual values observable. Each piece of state is a signal-like observable, and components re-render only for the exact keys they accessed — not for the slice, not for the store, but for the specific deeply-nested value.

This makes Legend-State the performance leader for high-frequency update scenarios. If you have a table with 500 rows and only row 47 changes, only the component rendering row 47 re-renders. Zustand with even a carefully crafted selector would re-render the component subscribing to the array slice because the array reference changed.

The observable() function is the entry point. You pass it a plain object and get back a deeply reactive graph. Access values with .get(), update them with .set(). Components wrapped with the observer() higher-order component automatically subscribe to every observable they access during render.

// 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 CartItemRow = observer(function CartItemRow({ 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

The biggest differentiator is the sync plugin system. Legend-State ships with first-class integrations for Supabase, localStorage, SQLite, and more. You define your store as a synced observable, and Legend-State handles reading from the local cache immediately (instant load), syncing with the server in the background, applying optimistic updates, and reconciling conflicts. This is the infrastructure for offline-first apps that would otherwise take weeks to build.

// 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

The trade-off is API complexity. Legend-State's .get() / .set() pattern is different from what most React developers expect, and the observer() HOC adds a wrapping step. The library is also newer and smaller than Zustand's ecosystem, so community resources and third-party middleware are more limited. But for applications where fine-grained reactivity or offline sync is a core requirement, Legend-State's capabilities are unique in the React ecosystem.


Valtio: Proxy Magic

Valtio (~700K weekly downloads) occupies an interesting middle ground: it provides the ergonomics of direct mutation that MobX pioneered, but without classes, decorators, or observable annotations. You define state as a plain object, Valtio wraps it in a JavaScript Proxy, and mutations look exactly like regular property assignments.

The core API has two functions. proxy() creates a reactive state object. useSnapshot() subscribes a React component to a read-only snapshot of that state, automatically tracking which properties were accessed during render and only triggering re-renders when those specific properties change.

// 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>;
}

The mutation model is Valtio's strongest selling point. If you find Zustand's set() pattern unnatural — especially for nested updates — Valtio's direct assignment style is refreshing. There is no spread syntax, no immutability juggling, and no mental model shift between how state looks and how you change it. This makes Valtio particularly attractive for developers migrating from Vue's reactive system or from MobX.

Valtio also supports derived state through computed getters on the proxy object, and it has a subscribe() function for side effects outside of React. The proxyWithHistory and proxySet/proxyMap utilities cover more advanced use cases like undo/redo and typed collection state.

One important constraint: because Valtio relies on JavaScript Proxies, the snapshot you receive in useSnapshot() is immutable. You cannot mutate snap.items.push() — mutations must go through the original cartState proxy. This distinction trips up developers occasionally, but the TypeScript types make it obvious once you understand the pattern.


Performance Comparison

The three libraries handle re-renders differently, and this matters most for high-frequency updates or large lists.

Zustand subscriptions are based on selector return values. If your selector returns a primitive or a stable reference, the component doesn't re-render. The problem arises with object selectors — state => state.user returns the same reference unless user changes, which is fine, but state => ({ name: state.user.name, email: state.user.email }) creates a new object every render. useShallow fixes this with shallow equality, but it's an opt-in optimization you have to remember.

Valtio improves on this with automatic property-level tracking. useSnapshot() tracks which keys of the snapshot you actually read during render and only re-triggers when those specific keys change. You don't write selectors — Valtio figures it out from your render function. This is more ergonomic and harder to accidentally misuse.

Legend-State is the most granular. With observer(), each component tracks its own accessed observables independently. In a list of 1,000 items, Legend-State can update a single row without re-rendering the list container at all. This is the reactive model that signals libraries (Solid.js, Angular Signals) use, applied to React.

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

For a typical SaaS dashboard with modest update frequency, the difference between these three approaches is imperceptible. The distinction becomes meaningful in high-frequency scenarios: real-time collaboration tools, live data dashboards with many rows, or games. In those cases, Legend-State's architecture is the correct choice.


Package Health Table

LibraryWeekly DownloadsBundle SizeOutside ReactPersistence Built-inFine-Grained Reactivity
Zustand~3.2M~3KBYesVia middlewareNo (use selectors)
Valtio~700K~3KBYesNo (manual)Partial (snapshot tracking)
Legend-State~200K~14KBYesYes (sync plugins)Yes

When to Choose

Choose Zustand for the vast majority of React applications. It has the largest ecosystem, the most tutorials, and the simplest mental model. Zustand's middleware covers persistence, DevTools, and Immer-style mutations. The selector pattern is explicit, which makes subscriptions easy to audit and debug. If you're building a standard SaaS product, dashboard, or e-commerce app, Zustand is the right default.

Choose Legend-State when your application is offline-first or needs to handle high-frequency partial updates efficiently. The sync plugin system for Supabase and local databases is genuinely unique — no other React state manager gives you local-first architecture out of the box. If you're building a collaborative notes app, a mobile-first product with offline mode, or any interface where hundreds of items update in real time, Legend-State's fine-grained reactivity pays for itself.

Choose Valtio when you prefer the mutation model over Zustand's set() API. Coming from Vue, MobX, or just preferring more imperative-looking code, Valtio's proxy pattern is natural. It also works well for moderately complex state that doesn't need fine-grained Legend-State reactivity but would benefit from automatic property tracking over manual Zustand selectors. The bundle size is similar to Zustand, and the learning curve is low for developers already familiar with reactive programming concepts.


State Outside React Components

All three libraries support reading and writing state outside of React components, which is important for coordinating with non-React code — WebSocket event handlers, browser APIs like beforeunload, or server action callbacks in Next.js.

With Zustand, you call useCartStore.getState() to read and useCartStore.setState() to write outside of components. This makes it trivial to reset state on logout, update state from a WebSocket message handler, or read state from a utility function without passing props down.

With Valtio, you simply mutate the cartState proxy directly — no special API. The proxy is a regular JavaScript object outside of React, so cartState.items.push(item) works the same in an event handler as it does inside a component action function. This consistency is one of Valtio's most underrated qualities.

With Legend-State, you call observable.set() directly on the observable object. Any component currently mounted with observer() that accesses that observable will re-render. The same fine-grained reactivity works outside React — postsStore.data[0].title.set('Updated') will cause only the component rendering that specific post's title to re-render.

Migrating from Redux to Modern State Managers

Many teams are actively migrating away from Redux to Zustand, Valtio, or Legend-State. The migration is typically straightforward for Zustand because the mental model (store with actions, selectors for components) is similar. The main differences are:

Zustand stores are not global by default — each create() call produces an independent store. Redux's single store architecture is replaced by multiple focused stores. Most teams find this more maintainable, but it requires thinking about store boundaries during migration.

Redux action creators become plain functions in Zustand stores — no action types, no dispatch. The set() function is the only mechanism for state changes. If your Redux codebase uses thunks heavily, the equivalent in Zustand is simply an async function in the store that calls set() after awaiting.

Valtio's migration path from Redux is even more direct for teams that found Redux's immutable update patterns cumbersome. Valtio mutations are direct assignments, which feels closer to how many developers naturally think about state changes.

Legend-State migrations are less common because its API is most different from Redux's — the observable() / .get() / .set() pattern requires learning new conventions. But for teams migrating to an offline-first architecture, the sync plugins make the effort worthwhile.


The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.