Fix: Valtio Not Working — Component Not Re-rendering, Snapshot Stale, or Proxy Mutation Not Tracked
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 updateOr a snapshot shows stale data:
const snap = snapshot(state);
state.count++;
console.log(snap.count); // Still shows old valueOr deeply nested mutations aren’t tracked:
const state = proxy({ user: { address: { city: 'NYC' } } });
state.user.address.city = 'LA'; // Component doesn't re-renderOr 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
proxyobject is for mutations. Thesnapshotis the read-only view for rendering. In React, always useuseSnapshot()inside components to get a reactive view. - Snapshots are point-in-time copies —
snapshot(state)creates an immutable snapshot of the current state. Subsequent mutations don’t update the snapshot — it’s frozen. Create a new snapshot or useuseSnapshot()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.
useSnapshotre-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 proxyFix 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Jotai Not Working — Atom Not Updating, Derived Atom Out of Sync, or atomWithStorage Hydration Error
How to fix Jotai state management issues — atom scope, derived atoms, async atoms with Suspense, atomWithStorage SSR, useAtomValue vs useSetAtom, and debugging stale state.
Fix: TanStack Query Not Working — Data Not Fetching, Cache Not Updating, or Mutation Not Triggering Re-render
How to fix TanStack Query (React Query v5) issues — query keys, stale time, enabled flag, mutation callbacks, optimistic updates, QueryClient setup, and SSR with prefetchQuery.
Fix: Zustand Not Working — Component Not Re-rendering, State Reset on Refresh, or Selector Causing Infinite Loop
How to fix Zustand state management issues — selector optimization, persist middleware, shallow comparison, devtools setup, slice pattern for large stores, and common subscription mistakes.
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.