Skip to content

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

FixDevs ·

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.

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)

For related state management issues, see Fix: Zustand Not Working and Fix: Jotai Not Working.

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