Skip to content

Fix: Valtio Not Working — Component Not Re-rendering, Snapshot Stale, or Proxy Mutation Not Tracked

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Valtio state management issues — proxy vs snapshot, useSnapshot for React, subscribe for side effects, derived state with computed, async actions, and Valtio with React Server Components.

The Problem

A component doesn’t re-render after mutating Valtio state:

const state = proxy({ count: 0 });

function Counter() {
  // Reading directly from proxy — not reactive
  return <div>{state.count}</div>;
}

state.count++;  // Mutation happens but Counter doesn't update

Or a snapshot shows stale data:

const snap = snapshot(state);
state.count++;
console.log(snap.count);  // Still shows old value

Or deeply nested mutations aren’t tracked:

const state = proxy({ user: { address: { city: 'NYC' } } });
state.user.address.city = 'LA';  // Component doesn't re-render

Or useSnapshot causes more re-renders than expected:

function UserCard() {
  const snap = useSnapshot(state);
  // Re-renders on every state change, even unrelated ones
  return <div>{snap.user.name}</div>;
}

Why This Happens

Valtio uses JavaScript Proxy objects and tracks access patterns to minimize re-renders:

  • Direct proxy reads in JSX are not reactive — the proxy object is for mutations. The snapshot is the read-only view for rendering. In React, always use useSnapshot() inside components to get a reactive view.
  • Snapshots are point-in-time copiessnapshot(state) creates an immutable snapshot of the current state. Subsequent mutations don’t update the snapshot — it’s frozen. Create a new snapshot or use useSnapshot() inside a component for reactive updates.
  • Nested objects are automatically proxied — Valtio recursively wraps nested objects in proxies. Mutations at any depth are tracked, but only if the parent object was accessed via the proxy.
  • useSnapshot re-renders only for accessed properties — Valtio tracks which properties were accessed during render. It only re-renders the component when those specific properties change. This is automatic and fine-grained — you don’t need selectors.

The mental model that breaks people is the proxy/snapshot duality. Outside React you mutate the proxy. Inside React you read the snapshot. Mixing them in either direction produces the bug: writing to snap.count silently fails because the snapshot is frozen, and reading state.count directly in JSX bypasses the tracking that drives re-renders. Valtio does not throw when you make this mistake; the component simply doesn’t update, which is why “not working” reports almost always trace back to this rule.

A second source of friction shows up under React 18 concurrent rendering. useSnapshot uses useSyncExternalStore under the hood, so it is tear-safe across concurrent transitions. But if you mutate the proxy inside a useEffect cleanup or during a Suspense fallback, you can produce snapshot reads that don’t match what the user sees. The fix is to keep mutations out of render paths and inside event handlers or effects, the same discipline you’d use with useState.

How Other Tools Handle This

Valtio sits in a busy React state-management field. The choice usually comes down to API ergonomics, not capability.

Valtio vs Zustand. Zustand uses a store-with-selectors pattern: you call create() to define state plus actions, then read with useStore(s => s.count). The selector controls re-renders explicitly. Valtio’s useSnapshot is implicit — it watches every property you touched during render. Zustand is more predictable for engineers used to Redux-style discipline; Valtio is shorter for engineers who want to mutate state like a regular object. Both ship in ~3–4 KB.

Valtio vs Jotai. Jotai is atom-based. Each piece of state is a separate atom, and components subscribe by reading the atom with useAtom. Atoms compose into derived atoms that update only when dependencies change. Jotai shines for highly granular state (per-row form fields, per-cell grid edits). Valtio’s proxy model fits whole-object state (a user, a cart) better — composing many atoms in Jotai is verbose compared to one nested proxy.

Valtio vs Recoil. Recoil pioneered the atom/selector approach but development has slowed since 2023 and the library is no longer recommended for new code by Facebook engineers who built it. Jotai is the spiritual successor. If you’re maintaining Recoil, the migration path is to Jotai, not Valtio.

Valtio vs MobX. MobX is the original proxy/observable state library and the obvious predecessor to Valtio. MobX has a richer ecosystem (computed values, reactions, autorun, observer HOC) and works with class-based components and outside React. Valtio is lighter and React-first. If you already use MobX, there is no reason to migrate; if you’re starting fresh, Valtio’s useSnapshot is the smaller surface area.

Valtio vs Pinia (Vue). Pinia is the Vue equivalent — proxy-based, mutate-directly, computed properties. The API ideas are nearly identical, which is unsurprising since both are influenced by Vue’s reactivity primitives. The takeaway: if you’ve used Pinia, Valtio will feel familiar.

Picking the right tool. Use Zustand when you want a single store with explicit selectors and a familiar action pattern. Use Jotai when state is granular and atoms compose naturally. Use Valtio when state is object-shaped and mutating directly feels natural. Use MobX when you have legacy MobX or need observable patterns outside React. All four are React 18 concurrent-safe; the choice is ergonomic, not capability-driven.

Fix 1: Read State Correctly in React

import { proxy, useSnapshot } from 'valtio';

// Define state
const state = proxy({
  count: 0,
  user: {
    name: 'Alice',
    email: '[email protected]',
  },
  items: ['apple', 'banana'],
});

// WRONG — reads proxy directly, not reactive
function Counter() {
  return <div>{state.count}</div>;  // Never updates
}

// CORRECT — use useSnapshot inside the component
function Counter() {
  const snap = useSnapshot(state);
  return <div>{snap.count}</div>;  // Reactive — updates when state.count changes
}

// CORRECT — fine-grained reactivity (only re-renders when accessed props change)
function UserCard() {
  const snap = useSnapshot(state);

  // Only accesses snap.user.name — re-renders ONLY when name changes
  // Even if email or count change, this component won't re-render
  return <div>{snap.user.name}</div>;
}

// Mutations always happen on the proxy, never on snap
function Controls() {
  const snap = useSnapshot(state);

  return (
    <div>
      <p>{snap.count}</p>
      <button onClick={() => state.count++}>+</button>     {/* Correct */}
      <button onClick={() => snap.count++}>+</button>      {/* WRONG — snap is read-only */}
    </div>
  );
}

Mutations outside React:

// Mutate directly — no need for setters or reducers
state.count++;
state.user.name = 'Bob';
state.items.push('cherry');
state.items = [...state.items, 'date'];  // Replace array also works

// Async mutations
async function loadUser(id: number) {
  const user = await fetchUser(id);
  state.user = user;  // Direct assignment is fine
}

// Object spread doesn't work with proxies as expected
// WRONG:
// const newUser = { ...state.user, name: 'Bob' };
// state.user = newUser;  // This works but loses proxy tracking for the new object

// CORRECT — mutate in place
state.user.name = 'Bob';

// OR if replacing the whole object:
Object.assign(state.user, fetchedUser);  // Merge into existing proxy

Fix 2: Use subscribe for Side Effects

import { proxy, subscribe, subscribeKey } from 'valtio';

const state = proxy({ count: 0, user: null });

// Subscribe to all changes in the proxy
const unsubscribe = subscribe(state, () => {
  console.log('State changed:', state.count, state.user);
  // Runs synchronously after any mutation
  localStorage.setItem('state', JSON.stringify(state));
});

// Unsubscribe when done
unsubscribe();

// subscribeKey — subscribe to a specific property
const stopWatching = subscribeKey(state, 'count', (count) => {
  console.log('Count changed to:', count);
  analyticsTrack('count_changed', { count });
});

// Subscribe to nested changes
const stopWatchingUser = subscribe(state.user, () => {
  // Fires when any user property changes
  syncUserToServer(snapshot(state.user));
});

// React hook for subscription (outside React component tree)
import { useEffect } from 'react';

function useStateSync() {
  useEffect(() => {
    const unsub = subscribe(state, () => {
      localStorage.setItem('app-state', JSON.stringify(snapshot(state)));
    });
    return unsub;
  }, []);
}

Fix 3: Derived State with computed

import { proxy, computed } from 'valtio';

const state = proxy({
  items: [
    { id: 1, name: 'Apple', price: 1.5, inCart: false },
    { id: 2, name: 'Banana', price: 0.5, inCart: true },
    { id: 3, name: 'Cherry', price: 3.0, inCart: true },
  ],
});

// computed — derived state that auto-updates
const derived = {
  cartItems: computed(() => state.items.filter(i => i.inCart)),
  cartTotal: computed(() =>
    state.items
      .filter(i => i.inCart)
      .reduce((sum, i) => sum + i.price, 0)
  ),
  itemCount: computed(() => state.items.length),
};

// Use in component
function CartSummary() {
  const snap = useSnapshot(derived);

  return (
    <div>
      <p>{snap.cartItems.length} items</p>
      <p>Total: ${snap.cartTotal.toFixed(2)}</p>
    </div>
  );
}

// Or define computed directly on the proxy
const store = proxy({
  price: 10,
  quantity: 3,
  get total() {  // Getter is automatically reactive
    return this.price * this.quantity;
  },
});

Fix 4: Async Actions and Loading State

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

interface AppState {
  users: User[];
  loading: boolean;
  error: string | null;
}

const state = proxy<AppState>({
  users: [],
  loading: false,
  error: null,
});

// Action functions that mutate state
async function fetchUsers() {
  state.loading = true;
  state.error = null;

  try {
    const users = await api.getUsers();
    state.users = users;   // Direct assignment
  } catch (err) {
    state.error = String(err);
  } finally {
    state.loading = false;
  }
}

// Component using the action
function UserList() {
  const snap = useSnapshot(state);

  useEffect(() => {
    fetchUsers();  // Call action — doesn't need to be inside component
  }, []);

  if (snap.loading) return <Spinner />;
  if (snap.error) return <Error message={snap.error} />;

  return (
    <ul>
      {snap.users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// Proxymap and proxyset for Map/Set
import { proxyMap, proxySet } from 'valtio/utils';

const state = proxy({
  userMap: proxyMap<number, User>(),    // Reactive Map
  selectedIds: proxySet<number>(),      // Reactive Set
});

state.userMap.set(1, { id: 1, name: 'Alice' });
state.selectedIds.add(1);

// In component
function UserDisplay() {
  const snap = useSnapshot(state);

  return (
    <div>
      {[...snap.userMap.values()].map(user => (
        <div
          key={user.id}
          style={{ fontWeight: snap.selectedIds.has(user.id) ? 'bold' : 'normal' }}
        >
          {user.name}
        </div>
      ))}
    </div>
  );
}

Fix 5: Split State into Multiple Stores

// auth.ts — auth store
export const authState = proxy<{
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
}>({
  user: null,
  token: null,
  get isAuthenticated() { return this.user !== null; },
});

export async function login(credentials: Credentials) {
  const { user, token } = await api.login(credentials);
  authState.user = user;
  authState.token = token;
}

export function logout() {
  authState.user = null;
  authState.token = null;
}

// cart.ts — cart store
export const cartState = proxy({
  items: [] as CartItem[],
  get total() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  },
});

export function addToCart(product: Product) {
  const existing = cartState.items.find(i => i.id === product.id);
  if (existing) {
    existing.quantity++;
  } else {
    cartState.items.push({ ...product, quantity: 1 });
  }
}

// Use in components — import what you need
import { authState } from './auth';
import { cartState, addToCart } from './cart';

function Header() {
  const auth = useSnapshot(authState);
  const cart = useSnapshot(cartState);

  return (
    <header>
      {auth.isAuthenticated ? <span>{auth.user!.name}</span> : <LoginButton />}
      <span>{cart.items.length} items — ${cart.total.toFixed(2)}</span>
    </header>
  );
}

Fix 6: Debug Valtio State

import { devtools } from 'valtio/utils';

// Connect to Redux DevTools
const state = proxy({ count: 0 });
devtools(state, { name: 'App State', enabled: process.env.NODE_ENV !== 'production' });

// Manual snapshot inspection
import { snapshot } from 'valtio';

// Get a plain object copy (useful for debugging and serialization)
const plain = snapshot(state);
console.log(JSON.stringify(plain));  // Logs as plain JSON

// Check if a value is a proxy
import { getVersion, isChanged } from 'valtio/utils';

const s1 = snapshot(state);
state.count++;
const s2 = snapshot(state);

isChanged(s1, s2);  // true — something changed
isChanged(s1.user, s2.user);  // false — user didn't change

// Useful for optimizing re-renders:
function ExpensiveComponent() {
  const snap = useSnapshot(state);
  // Only re-renders when accessed properties change
  // Valtio tracks exactly what you read
  return <div>{snap.count}</div>;
  // This component will NOT re-render if snap.user changes
}

Still Not Working?

Mutation triggers re-render but component shows old value — you might be reading from the proxy instead of the snapshot in some places. In a component, state.count reads the raw proxy value (may not trigger re-render). snap.count (from useSnapshot) is what React watches. Search your component for any direct references to state.* that should be snap.*.

Array mutations not detected — Valtio proxies arrays and tracks standard array mutations (push, pop, splice, sort, etc.). Direct index assignment (state.items[0] = newItem) is also tracked. However, replacing the entire array with the same reference doesn’t trigger an update:

// WRONG — same reference, no change detected
const arr = state.items;
arr.push(item);
state.items = arr;  // Same reference

// CORRECT — push directly on proxy
state.items.push(item);

// CORRECT — replace with new array
state.items = [...state.items, item];

useSnapshot with sync: true for synchronous updates — by default, useSnapshot batches updates. For tests or cases where you need synchronous snapshot updates:

const snap = useSnapshot(state, { sync: true });
// Now snap updates synchronously with state mutations
// Use for tests, not in production (can cause extra renders)

Class instances lose their methods after snapshotsnapshot() recursively freezes plain objects but preserves class instances by reference. However, if a class instance is nested inside a proxy and you mutate one of its properties, the snapshot still reflects the original instance — not a deep clone. For class-based state, prefer plain objects or use proxyMap/proxySet with stable identifiers, otherwise equality checks downstream may misbehave.

React Server Components can’t import Valtio stores — Valtio’s proxy machinery is client-only; importing proxy() from a server component throws at build time. Mark any module that creates or reads a Valtio store with "use client" at the top, and keep the import graph clean of server-only utilities.

Time-travel debugging via Redux DevTools shows stale state — the devtools integration serializes via snapshot() on each mutation. If you mutate quickly in a loop, DevTools may batch and miss intermediate states. For replay-grade accuracy, wrap mutations in named actions and snapshot manually between them.

For related state management issues, see Fix: Zustand Not Working and Fix: Jotai Not Working. For other React-side reactivity gotchas, see Fix: React Context Not Updating and Fix: React useState Not Updating.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles