Chapter 14

Atomic State: Jotai, Valtio and Recoil Compared

The Atomic State Concept: Fine-Grained Reactivity

Most React application performance problems don't come from having too much state โ€” they come from subscriptions that are too coarse. When you have a global store with 100 fields and each component subscribes to the entire store, any field change forces every subscriber to re-render. Selectors help, but they're a patch applied to a fundamentally monolithic data model.

Atomic state management inverts this model. State is decomposed into the smallest independent units possible โ€” atoms โ€” and each component subscribes only to the atoms it actually reads. When atom A changes, only the components that depend on A re-render. Components that depend only on B and C are unaffected. This is true fine-grained reactivity, more thorough in concept than the selector-based approach.

Three libraries implement this idea, each with a distinct philosophy:

Jotai: The Minimal Atom Philosophy

Jotai borrows Recoil's atom concept and aggressively strips away everything that isn't essential: no RecoilRoot configuration complexity, no string keys to manage, no conceptual separation between atoms and selectors. An atom is just a value with built-in reactivity.

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';

// atom() creates an independent reactive state unit
const countAtom = atom(0);
const textAtom = atom('hello');
const userAtom = atom<User | null>(null);

// Three hooks with precise semantic differences
function Counter() {
  // useAtom: equivalent to useState โ€” returns [value, setter]
  const [count, setCount] = useAtom(countAtom);

  // useAtomValue: read-only subscription, re-renders only when atom changes
  const text = useAtomValue(textAtom);

  // useSetAtom: write-only โ€” this component NEVER re-renders due to state changes
  const setUser = useSetAtom(userAtom);

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

useSetAtom is Jotai's most underappreciated performance tool. If a component only needs to trigger state changes but never reads the state (a "clear cart" button, a "logout" handler), using useSetAtom ensures that component is completely immune to re-renders caused by state changes. This is the atom equivalent of subscribing to a stable function reference in Zustand.

Derived Atoms: Jotai's Most Powerful Feature

Derived atoms let you compute new state from other atoms while maintaining the fine-grained subscription model. The dependency tracking is automatic โ€” you don't declare dependencies, Jotai figures them out by observing which atoms you call get() on.

// Primitive atoms
const priceAtom = atom(100);
const quantityAtom = atom(1);
const discountPercentAtom = atom(0);
const selectedCurrencyAtom = atom<'USD' | 'EUR' | 'CNY'>('USD');

// Read-only derived atom: automatically tracks all dependencies
const subtotalAtom = atom(get => get(priceAtom) * get(quantityAtom));
const totalAtom = atom(get => {
  const subtotal = get(subtotalAtom);
  const discount = get(discountPercentAtom);
  return subtotal * (1 - discount / 100);
});
// totalAtom only recomputes when priceAtom, quantityAtom, or discountPercentAtom changes

// Read-write derived atom: has both getter and setter
const exchangeRatesAtom = atom({ USD: 1, EUR: 0.92, CNY: 7.2 });

const priceInCurrencyAtom = atom(
  // Getter: compute display price in selected currency
  (get) => {
    const usdPrice = get(priceAtom);
    const currency = get(selectedCurrencyAtom);
    const rates = get(exchangeRatesAtom);
    return (usdPrice * rates[currency]).toFixed(2);
  },
  // Setter: accept a price in the selected currency, convert back to USD
  (get, set, newPriceInCurrency: number) => {
    const currency = get(selectedCurrencyAtom);
    const rates = get(exchangeRatesAtom);
    const usdPrice = newPriceInCurrency / rates[currency];
    set(priceAtom, usdPrice);
  }
);

This bidirectional derivation pattern โ€” where you can both read a computed value and write back through it โ€” is something Redux selectors cannot do. It models real-world data transformations more naturally.

Async Atoms and Suspense Integration

Jotai has first-class support for async atoms, integrating naturally with React Suspense:

// An async atom: the getter returns a Promise
const currentUserIdAtom = atom<string | null>(null);

const userProfileAtom = atom(async (get) => {
  const userId = get(currentUserIdAtom);
  if (!userId) return null;

  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) throw new Error('Failed to fetch user');
  return response.json() as Promise<User>;
});

// Component usage with Suspense
function UserProfile() {
  // useAtomValue will throw the Promise when data isn't ready,
  // which Suspense catches and shows the fallback
  const user = useAtomValue(userProfileAtom);
  if (!user) return <p>Not logged in</p>;
  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <ErrorBoundary fallback={<ErrorMessage />}>
        <UserProfile />
      </ErrorBoundary>
    </Suspense>
  );
}

Jotai Utilities: atomWithStorage and atomWithReducer

import { atomWithStorage, atomWithReducer } from 'jotai/utils';

// Automatically syncs with localStorage (or sessionStorage, IndexedDB, etc.)
const themeAtom = atomWithStorage<'light' | 'dark'>('app-theme', 'light');
// Reading themeAtom hydrates from localStorage; writing persists automatically

// Atom with Redux-style reducer (useful for complex state transitions)
type CountAction = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };

const countReducer = (prev: number, action: CountAction): number => {
  switch (action.type) {
    case 'increment': return prev + 1;
    case 'decrement': return Math.max(0, prev - 1);
    case 'reset': return 0;
  }
};

const countAtomWithReducer = atomWithReducer(0, countReducer);

function Counter() {
  const [count, dispatch] = useAtom(countAtomWithReducer);
  return (
    <>
      <span>{count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </>
  );
}

Valtio: Proxy-Driven Mutable State

Valtio asks a question that sounds almost provocative: if JavaScript already has perfect data structures (objects and arrays), why not just mutate them directly?

Valtio wraps your state in an ES6 Proxy that tracks reads (subscription) and writes (triggers). Your code looks exactly like plain JavaScript object manipulation. The Proxy maintains the reactive subscription graph invisibly.

import { proxy, useSnapshot } from 'valtio';

// Create a reactive store โ€” it's just an object
const store = proxy({
  count: 0,
  user: null as User | null,
  cart: {
    items: [] as CartItem[],
    discount: 0,
  },
});

// Mutate from anywhere โ€” the Proxy handles subscription notification
function addToCart(item: CartItem) {
  store.cart.items.push(item); // Direct mutation, anywhere in your codebase
}

function applyDiscount(pct: number) {
  store.cart.discount = pct;
}

// In React components: useSnapshot creates an immutable snapshot
function CartView() {
  // snap is a frozen snapshot of the current state
  // Valtio tracks which properties of snap were accessed during render
  // Only re-renders when those specific properties change
  const snap = useSnapshot(store);

  return (
    <div>
      {/* Accessing snap.cart.items.length โ€” subscribed to items array length */}
      <p>{snap.cart.items.length} items in cart</p>
      {snap.cart.items.map(item => (
        <div key={item.id}>
          {item.name} ร— {item.quantity} = ${(item.price * item.quantity).toFixed(2)}
        </div>
      ))}
      <strong>Discount: {snap.cart.discount}%</strong>
    </div>
  );
}

The subscription granularity is at the property access level. If CartView only accesses snap.cart.items.length and snap.cart.discount, it will only re-render when those specific values change. Mutating store.user will not trigger a re-render in CartView.

Derived State with derive

import { derive } from 'valtio/utils';
import { subscribeKey } from 'valtio/utils';

// derive creates a read-only proxy with computed properties
const derived = derive({
  cartTotal: (get) => {
    const cart = get(store).cart;
    const subtotal = cart.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    return subtotal * (1 - cart.discount / 100);
  },
  itemCount: (get) => get(store).cart.items.length,
});

function CartSummary() {
  const snap = useSnapshot(derived);
  return (
    <div>
      <span>{snap.itemCount} items</span>
      <span>Total: ${snap.cartTotal.toFixed(2)}</span>
    </div>
  );
}

// Subscribe to changes outside React (for analytics, side effects, etc.)
subscribeKey(store.cart, 'items', (items) => {
  analytics.track('cart_updated', { itemCount: items.length });
});

Valtio's Tradeoff: Concurrent Mode Tension

Valtio's direct mutation model has a theoretical tearing risk under React Concurrent Mode. When React pauses and resumes rendering, it might read the Proxy state at different moments, potentially seeing intermediate mutation states. Valtio mitigates this through useSnapshot โ€” the snapshot is immutable for the duration of the render. However, Valtio does not use useSyncExternalStore, which is the React-blessed API for external state synchronization.

In practice, most applications won't encounter this tearing scenario. But for financial applications or any context where data consistency is a hard requirement, this theoretical risk is worth weighing.

Recoil: Facebook's Atom Experiment

Recoil, released by the Facebook team in 2020, is the most concept-rich of the atomic state libraries. It introduced a formal atom/selector duality and a comprehensive async state handling system built around Suspense.

import {
  atom,
  selector,
  useRecoilState,
  useRecoilValue,
  useSetRecoilState,
  RecoilRoot,
} from 'recoil';

// Atoms require unique string keys (used for persistence and DevTools)
const countAtom = atom({
  key: 'counter/count',
  default: 0,
});

const userIdAtom = atom<string | null>({
  key: 'auth/userId',
  default: null,
});

// selector: memoized derived computation, auto-tracks atom dependencies
const userProfileSelector = selector({
  key: 'user/profile',
  get: async ({ get }) => {
    const userId = get(userIdAtom);
    if (!userId) return null;
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  },
});

const doubleCountSelector = selector({
  key: 'counter/double',
  get: ({ get }) => get(countAtom) * 2,
});

function Counter() {
  const [count, setCount] = useRecoilState(countAtom);
  const double = useRecoilValue(doubleCountSelector);

  return (
    <div>
      <p>Count: {count}, Double: {double}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
    </div>
  );
}

// RecoilRoot is required at the application root
function App() {
  return (
    <RecoilRoot>
      <Counter />
    </RecoilRoot>
  );
}

An Honest Assessment of Recoil in 2024-2025

Recoil's development has effectively stalled. The latest stable version remains 0.7.x, key team members have left Meta, and the GitHub repository has accumulated hundreds of unresolved issues. React 19 compatibility requires --legacy-peer-deps to force installation, which is a significant warning sign for production use.

For new projects, Jotai is the clear successor โ€” it implements the same atom-first philosophy with a cleaner API (no string keys, no RecoilRoot requirement), active maintenance, and full React 19 compatibility.

Performance Comparison: Which Library Updates the Fewest Components

Test scenario: shared state containing { count: number, name: string, items: Item[] }. 50 components subscribe to different parts. Only count is updated.

Library Re-renders on count update Why
React Context All 50 Context broadcasts to all consumers
Zustand (no selector) All 50 Component subscribed to entire store
Zustand (with selector) Only count subscribers Object.is comparison on selector output
Jotai Only countAtom subscribers Atom-level isolation, automatic
Valtio Only count-accessing components Proxy tracks property access automatically
Redux + RTK (no selector) All 50 useSelector runs every render
Redux + RTK (with selector) Only count subscribers reselect memoization

The key insight: atomic libraries (Jotai, Valtio) achieve fine-grained subscriptions with zero configuration; selector-based libraries (Zustand, Redux) require correct selector discipline to reach the same performance. If your team consistently writes precise selectors, the practical difference is small. If your team tends to skip selectors, atomic libraries provide a safer default.

Bundle Size Comparison

Bundle size matters for initial load time, particularly on mobile networks:

Zustand:         ~8KB   (minified + gzipped)
Jotai:           ~13KB  (core) / ~19KB (with utils)
Valtio:          ~16KB
Redux Toolkit:   ~47KB  (+ react-redux ~18KB = ~65KB total)
Recoil:          ~76KB  โ† significantly heavier

Zustand is the smallest option by a significant margin. For mobile-first applications or situations where bundle budget is tight, this matters. Recoil's size relative to its maintenance status makes it difficult to justify for new projects.

React 19 Compatibility

React 19's major additions โ€” the React Compiler (automatic memoization), the use() hook, and stable Server Components โ€” have compatibility implications for state libraries:

Library React 19 Status
Zustand Full compatibility โ€” useSyncExternalStore is a React official API
Jotai v2+ Full compatibility โ€” migrated to useSyncExternalStore in v2
Valtio Compatible, but concurrent mode tearing risk remains theoretical
Recoil Requires --legacy-peer-deps; not recommended for new projects
Redux Toolkit Full compatibility; enterprise support and long-term maintenance

The React Compiler deserves a special note. It automatically memoizes component renders by analyzing dependencies at build time. This means many of the performance arguments for atomic state libraries (preventing unnecessary re-renders) become less relevant when the Compiler is enabled โ€” components that don't need to re-render simply won't, regardless of which state library you use. The Compiler doesn't eliminate the value of atomic libraries, but it does narrow the performance gap.

The Decision Tree: Choosing the Right Library

Choose Zustand when:

Choose Jotai when:

Choose Valtio when:

Choose Redux Toolkit when:

Avoid Recoil for new projects: Maintenance stalled, React 19 compatibility issues, and Jotai provides a superior alternative with the same conceptual model.

The "Atom Explosion" Risk

Before choosing an atomic library, acknowledge the opposite problem from monolithic stores: atom sprawl. When every piece of state gets its own atom, applications with hundreds of atoms develop complex dependency graphs that are just as hard to reason about as a large Redux store โ€” and they have no equivalent of the Redux action log to help you trace what changed and why.

The discipline required for atomic state management is different from Redux, not necessarily less:

// This is manageable
const countAtom = atom(0);
const nameAtom = atom('');
const isOpenAtom = atom(false);

// This starts getting complex
const userPrefsAtom = atom<UserPrefs>(defaultPrefs);
const derivedA = atom(get => compute(get(userPrefsAtom)));
const derivedB = atom(get => compute2(get(derivedA), get(countAtom)));
const derivedC = atom(async get => {
  const b = await get(derivedB);
  return fetch(`/api?b=${b}`).then(r => r.json());
});
// Now you have an implicit dependency graph that no tool visualizes for you

Atomic state management is not simpler than Redux โ€” it's differently complex. Redux makes coordination explicit through actions. Atomic libraries make coordination implicit through the dependency graph. Choose the complexity model that matches how your team thinks about state.

The right state management choice is ultimately about which model produces the fewest surprises for your team over the life of the project. No library wins on every dimension. Understanding the tradeoffs โ€” not just the API surface โ€” is the difference between choosing a tool and actually being served by it.

Rate this chapter
4.6  / 5  (20 ratings)

๐Ÿ’ฌ Comments