Skip to main content

MobX vs Valtio vs Legend-State: Observable State Management in 2026

·PkgPulse Team

TL;DR

Valtio is the pragmatic choice — simple proxy-based state, tiny API surface, plays nicely with React's render model, 4.7kB. MobX is the battle-tested enterprise option with observable classes, computed values, reactions, and 12+ years of production use. Legend-State is the performance champion with fine-grained reactivity that can eliminate most React re-renders — it's the best choice for highly dynamic UIs. All three use JavaScript Proxies for reactive state, but their mental models and ergonomics differ significantly.

Key Takeaways

  • Valtio: 1.8M+ weekly downloads, proxy state with snapshot() for immutable reads, React-integrated via useSnapshot
  • MobX: 3.1M+ weekly downloads, full observable system (classes, decorators, computed, reactions), biggest ecosystem
  • Legend-State: 400k+ weekly downloads, sub-millisecond reactive updates, fine-grained signals-style rendering
  • Bundle size: Valtio 4.7kB | MobX 19.7kB | Legend-State 6.2kB
  • TypeScript: All three have excellent TS support in their latest versions
  • When to use: Valtio for simplicity → MobX for complexity → Legend-State for performance

The Observable State Model

Unlike atom-based state (Jotai, Recoil) or reducers (Redux, Zustand), observable state libraries wrap your objects in JavaScript Proxies that track which parts of state were accessed during render:

User accesses store.user.name
→ Library records: "this component reads user.name"
→ When user.name changes
→ Only components that read user.name re-render

This is called fine-grained reactivity — components subscribe to exactly the data they use, nothing more.


Valtio: Minimal Proxy State

npm install valtio  # 4.7kB gzipped

Valtio's API is intentionally minimal. State is just a JavaScript object wrapped in proxy():

import { proxy, useSnapshot } from "valtio";

// 1. Create state — just an object
const store = proxy({
  user: {
    name: "Alice",
    email: "alice@example.com",
    preferences: {
      theme: "dark",
      notifications: true,
    },
  },
  cart: {
    items: [] as CartItem[],
    total: 0,
  },
});

// 2. Mutate directly — Valtio tracks changes
function addToCart(item: CartItem) {
  store.cart.items.push(item);
  store.cart.total += item.price;
}

// 3. Read in React — useSnapshot creates immutable snapshots
function CartSummary() {
  const snap = useSnapshot(store.cart); // subscribes to cart changes only

  return (
    <div>
      <p>{snap.items.length} items</p>
      <p>${snap.total.toFixed(2)}</p>
    </div>
  );
}

// 4. Computed values with derive
import { derive } from "valtio/utils";

const derived = derive({
  itemCount: (get) => get(store.cart).items.length,
  isEmpty: (get) => get(store.cart).items.length === 0,
});

Valtio's Mental Model

Valtio separates mutable state (proxy) from readable snapshots (useSnapshot):

// ✅ Mutations: use the proxy directly
store.user.name = "Bob";             // triggers re-render for components reading user.name
store.cart.items.push(newItem);      // triggers re-render for components reading cart.items

// ✅ Reads in React: always use useSnapshot
function UserName() {
  const snap = useSnapshot(store.user);
  return <h1>{snap.name}</h1>; // re-renders only when user.name changes
}

// ❌ Reading proxy directly in render (don't do this — bypasses React subscription)
function BadComponent() {
  return <h1>{store.user.name}</h1>; // won't re-render!
}

Valtio Outside React

import { subscribe, getVersion } from "valtio";

// Subscribe to changes (non-React)
const unsub = subscribe(store.user, () => {
  console.log("user changed:", store.user.name);
});

// Snapshot for non-reactive reads
import { snapshot } from "valtio";
const snap = snapshot(store); // deep immutable clone

// Clean up
unsub();

MobX: Full Observable System

npm install mobx mobx-react-lite  # 19.7kB gzipped (mobx alone)

MobX is a comprehensive reactive programming library. It's been around since 2015 and powers the state management of major apps (Microsoft, Netflix, Amazon have all used it).

MobX with Classes (Traditional)

import { makeObservable, observable, computed, action, runInAction } from "mobx";
import { observer } from "mobx-react-lite";

class CartStore {
  items: CartItem[] = [];
  isLoading = false;

  constructor() {
    makeObservable(this, {
      items: observable,
      isLoading: observable,
      total: computed,         // derived from items
      isEmpty: computed,
      addItem: action,         // mutates state
      clearCart: action,
      fetchCart: action,
    });
  }

  get total() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  get isEmpty() {
    return this.items.length === 0;
  }

  addItem(item: CartItem) {
    const existing = this.items.find((i) => i.id === item.id);
    if (existing) {
      existing.quantity += 1;
    } else {
      this.items.push(item);
    }
  }

  clearCart() {
    this.items = [];
  }

  async fetchCart(userId: string) {
    this.isLoading = true;
    const data = await api.getCart(userId);
    runInAction(() => {
      this.items = data.items;
      this.isLoading = false;
    });
  }
}

const cartStore = new CartStore();

MobX React Integration

// observer() makes the component reactive
const CartSummary = observer(() => {
  // Accesses cartStore.total and items.length automatically tracked
  return (
    <div>
      <p>{cartStore.items.length} items</p>
      <p>${cartStore.total.toFixed(2)}</p>
      {cartStore.isEmpty && <p>Your cart is empty</p>}
    </div>
  );
});

MobX with Observable Objects (No Classes)

import { observable, action, computed } from "mobx";

const cart = observable({
  items: [] as CartItem[],

  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  },

  addItem: action(function(item: CartItem) {
    this.items.push(item);
  }),
});

MobX Reactions: Side Effects

MobX's reaction and autorun are powerful for side effects:

import { autorun, reaction, when } from "mobx";

// autorun: runs whenever any observed state changes
const dispose = autorun(() => {
  document.title = `${cartStore.items.length} items in cart`;
});

// reaction: only runs when specific value changes
const dispose2 = reaction(
  () => cartStore.total,
  (total) => {
    analytics.track("cart_value_changed", { total });
  }
);

// when: runs once when condition is true
when(
  () => cartStore.isLoading === false,
  () => console.log("Cart loaded!")
);

// Always clean up reactions
dispose();
dispose2();

Legend-State: Fine-Grained Performance

npm install @legendapp/state  # 6.2kB gzipped

Legend-State takes fine-grained reactivity further than Valtio or MobX. It's inspired by Solid.js's signal model and designed for maximum performance in complex UIs.

Legend-State Observables

import { observable, observe } from "@legendapp/state";
import { observer } from "@legendapp/state/react";

// Create observable
const cart$ = observable({
  items: [] as CartItem[],
  total: 0,
});

// Computed values
const itemCount$ = observable(() => cart$.items.get().length);
const isEmpty$ = observable(() => cart$.items.get().length === 0);

// Mutations
function addToCart(item: CartItem) {
  cart$.items.push(item);
  cart$.total.set((prev) => prev + item.price);
}

Legend-State React Integration

import { observer, useObservable } from "@legendapp/state/react";

// Option 1: observer() HOC (like MobX)
const CartSummary = observer(() => {
  // cart$.items.get() is automatically tracked
  const items = cart$.items.get();
  const total = cart$.total.get();

  return (
    <div>
      <p>{items.length} items</p>
      <p>${total.toFixed(2)}</p>
    </div>
  );
});

// Option 2: Reactive$ components — NO component re-renders!
import { Reactive, Memo } from "@legendapp/state/react";

// This renders only the <span> when items change — parent doesn't re-render
function CartSummary() {
  return (
    <div>
      <Memo>{() => <span>{cart$.items.get().length} items</span>}</Memo>
      <Memo>{() => <span>${cart$.total.get().toFixed(2)}</span>}</Memo>
    </div>
  );
}

The <Memo> component is Legend-State's signature feature — only the minimal DOM node re-renders when state changes, not the parent component.

Legend-State Performance

// Benchmark: 1000 items, each with independent state
// Scenario: Update a single item's quantity

// Zustand/Redux: re-renders all 1000 items (or needs memo)
// Valtio: re-renders component accessing items array
// MobX: re-renders component if observable, computed updates are atomic
// Legend-State: re-renders ONLY the single item's quantity display

This is why Legend-State is popular for:

  • Data grids with many rows
  • Real-time dashboards
  • Apps with frequent partial updates

Performance Comparison

BenchmarkValtioMobXLegend-StateZustand (baseline)
Initial render (1000 items)~18ms~22ms~15ms~20ms
Update 1 item (naive)8ms3ms0.5ms12ms
Update 1 item (with memo)2ms1ms0.5ms3ms
Bundle size4.7kB19.7kB6.2kB1.1kB
Memory overheadLowMediumLowMinimal

Legend-State's advantage comes from the <Memo> pattern — it skips React's reconciler entirely for targeted updates.


Developer Experience Comparison

Valtio

// Pros: feels like plain JavaScript
store.count += 1;           // mutation
store.items.push(item);     // array mutation
const snap = useSnapshot(store.count); // read in React

// Cons: snapshot discipline required
// ❌ store.count in render doesn't react
// ✅ useSnapshot(store).count reacts

MobX

// Pros: powerful, explicit, great DevTools
// Cons: more boilerplate, need to know action/computed/observable

class Store {
  @observable count = 0;            // requires makeObservable() or decorators
  @computed get doubled() { ... }
  @action increment() { this.count++ }
}

Legend-State

// Pros: fastest, Solid.js-like DX
// Cons: .get()/.set() everywhere, different mental model

const count$ = observable(0);
count$.set(count$.get() + 1);        // mutation
const value = count$.get();          // read
// <Memo>{() => <span>{count$.get()}</span>}</Memo>

Feature Comparison Table

FeatureValtioMobXLegend-State
ModelProxy snapshotObservable reactiveSignals/observables
Learning curve✅ Low⚠️ Medium⚠️ Medium
TypeScript✅ Excellent✅ Excellent✅ Excellent
Computed valuesderive()computed✅ Computed observables
Side effectssubscribeKeyreaction, autorunobserve()
DevTools✅ Redux DevTools✅ MobX DevTools✅ Chrome extension
Server stateCombine w/ Tanstack QueryCombine w/ Tanstack QueryBuilt-in sync support
PersistenceproxyWithHistorymakePersistable✅ Built-in persistObservable
React Server Components⚠️ Client only⚠️ Client only⚠️ Client only
Weekly downloads1.8M3.1M400k
GitHub stars8.7k27k3.2k
Bundle size4.7kB19.7kB6.2kB

Choosing Between Them

Choose Valtio if:

  • You want the simplest API
  • You're comfortable with the snapshot mental model
  • You're coming from Zustand and want reactivity
  • Bundle size matters (4.7kB)

Choose MobX if:

  • You need computed values and reactions out of the box
  • Your team has OOP/class-based background
  • You're building complex business logic with many derivations
  • You need the most mature ecosystem (12+ years)
  • You're migrating from a backend framework with similar reactive patterns

Choose Legend-State if:

  • Performance is critical (data grids, dashboards, real-time)
  • You want Solid.js-like reactivity in React
  • You're building collaborative/multiplayer features (it has CRDTs support)
  • You need built-in persistence and sync

Stick with Zustand if:

  • You don't need proxy-based reactivity
  • Your state is simple and selectors work fine
  • You want the smallest bundle size

Methodology

  • Compared MobX 6.13, Valtio 1.13, Legend-State 3.0 with React 19
  • Benchmarked render performance on a 1000-row data table with per-cell state updates
  • Measured bundle sizes using Bundlephobia (March 2026)
  • Analyzed npm download trends on PkgPulse (30-day rolling average)
  • Reviewed GitHub issues and community discussions for known limitations

Compare state management library downloads on PkgPulse — real-time npm trends.

Comments

Stay Updated

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