Valtio
Overview
Valtio makes React state management feel like plain JavaScript: mutate proxy objects directly and React re-renders automatically. No reducers, no actions, no selectors. Wrap an object in proxy(), mutate it anywhere, and components that read the changed properties re-render. Based on JavaScript Proxy, it tracks which properties each component uses and only re-renders when those specific properties change.
When to Use
- You want the simplest possible state management with minimal boilerplate.
- You are tired of Redux boilerplate or Zustand's
set()function. - You need to share state between components without prop drilling.
- State must be accessed or modified outside React (event handlers, WebSocket callbacks, timers).
- Your team prefers mutable patterns over immutable update patterns.
- The user explicitly asks for valtio, proxy-based state, or "mutable React state."
Prerequisites
- A React or Next.js project with TypeScript (recommended).
- Node.js and npm installed.
- On Windows host (primary), use PowerShell for all CLI commands.
Procedure
1. Install Valtio
npm install valtio
2. Create a Proxy Store
Define state as a plain object wrapped in proxy(). Mutate the proxy directly—no setState or set() needed.
// store/app.ts
import { proxy, useSnapshot } from "valtio";
export const appState = proxy({
user: null as { name: string; email: string } | null,
theme: "light" as "light" | "dark",
notifications: [] as Array<{ id: string; text: string; read: boolean }>,
sidebar: { open: true, width: 280 },
});
// Mutate directly — React components auto-update
export function login(user: { name: string; email: string }) {
appState.user = user;
}
export function toggleTheme() {
appState.theme = appState.theme === "light" ? "dark" : "light";
}
export function addNotification(text: string) {
appState.notifications.push({ id: crypto.randomUUID(), text, read: false });
}
export function markAllRead() {
appState.notifications.forEach((n) => { n.read = true; });
}
export function toggleSidebar() {
appState.sidebar.open = !appState.sidebar.open;
}
3. Read State in Components with useSnapshot
useSnapshot creates a read-only snapshot. The component only re-renders when properties it accesses change.
// components/Header.tsx
import { useSnapshot } from "valtio";
import { appState, toggleTheme, toggleSidebar } from "@/store/app";
export function Header() {
const snap = useSnapshot(appState);
return (
<header className="flex items-center justify-between p-4">
<button
<span>{snap.user?.name ?? "Guest"}</span>
<button
{snap.theme === "light" ? "🌙" : "☀️"}
</button>
</header>
);
}
4. Derived / Computed Values with derive
// store/derived.ts
import { derive } from "valtio/utils";
import { appState } from "./app";
export const derived = derive({
unreadCount: (get) => get(appState).notifications.filter((n) => !n.read).length,
isDarkMode: (get) => get(appState).theme === "dark",
isLoggedIn: (get) => get(appState).user !== null,
});
5. Subscribe to Changes Outside React
Use subscribe() for side effects: persistence, logging, WebSocket sync.
import { subscribe } from "valtio";
import { appState } from "./store/app";
// Log every state change
subscribe(appState, () => {
console.log("State changed:", JSON.stringify(appState));
});
// Subscribe to a specific nested object
subscribe(appState.sidebar, () => {
localStorage.setItem("sidebar-open", String(appState.sidebar.open));
});
// Mutate from a WebSocket handler — components auto-update
socket.on("notification", (data) => {
appState.notifications.push(data);
});
6. Enable Redux DevTools (Optional)
import { devtools } from "valtio/utils";
import { appState } from "./store/app";
devtools(appState, { name: "appState", enabled: true });
Examples
Example 1: Shopping Cart
User prompt: "Build a shopping cart with add/remove/update quantity using simple state management."
- Create a Valtio proxy for cart state (
items: CartItem[],totalderived). - Write actions that directly mutate the array (
push,splice, quantity increment). - Build components that read via
useSnapshotand reactively display cart contents and total.
Example 2: Persisted Preferences
User prompt: "Store user preferences (theme, language, sidebar state) that persist across page loads."
- Create a Valtio store with preference fields.
- Use
subscribe()to sync each preference tolocalStorage. - On app init, hydrate the proxy from
localStoragebefore first render.
Guidelines
proxy()for state,useSnapshot()for reading — always use the snapshot in components.- Mutate directly —
state.count++works; nosetStateorset()needed. - Automatic render optimization — only re-renders when accessed properties change.
subscribe()for side effects — persist to localStorage, log, sync.derive()for computed values — auto-recalculates when dependencies change.- Works outside React — mutate from event handlers, WebSocket, timers.
- Snapshot is read-only — never mutate
snap; mutate the originalproxy. - Arrays work naturally —
push,splice,filterall trigger re-renders. - Nested objects tracked —
state.user.name = "new"triggers re-render. - Devtools —
import { devtools } from "valtio/utils"for Redux DevTools integration.
Pitfalls
- Mutating the snapshot instead of the proxy —
snap.count = 5silently fails or throws in dev. Always mutate the originalappState, notsnap. - Calling
useSnapshoton a non-proxy — the object must be wrapped inproxy()first or the hook will not track changes. - Over-rendering from derived filters — if a component reads
snap.notificationsand then filters, any change to any notification re-renders it. Usederive()to isolate computed values when needed. - Forgetting
subscribecleanup —subscribe()returns an unsubscribe function; call it on unmount or you will leak listeners. - Top-level replacement —
appState = newObjectdoes nothing useful; you must mutate properties in place (appState.user = newUser), not reassign the proxy variable. - Class instances and non-plain objects — Valtio proxies plain objects and arrays. Class instances may not track correctly unless you follow Valtio's guidance for custom classes.
- SSR / Next.js hydration mismatch — if the proxy is initialized from
localStorageorwindowat module scope, server and client can diverge. Hydrate insideuseEffector a client-only provider.
Verification
- Install check:
npm ls valtio
Expected output lists valtio with the installed version.
Runtime re-render check: Add a
console.log("render")inside a component usinguseSnapshot. Mutate an unrelated property on the proxy and confirm the component does not log. Mutate an accessed property and confirm it does log.Snapshot immutability check: In dev mode, attempt
snap.theme = "dark". Valtio should warn or throw that the snapshot is read-only.Subscribe check: Register a
subscribelistener, mutate the watched property, and confirm the callback fires. Call the returned unsubscribe function and confirm subsequent mutations no longer trigger it.Devtools check: Open Redux DevTools in the browser; confirm the store name appears and actions are recorded on each mutation.
Related Skills
- zustand — alternative minimal state library using
set()and selectors. - redux-toolkit — use when you need strict immutability, middleware, or time-travel debugging at scale.
- react-query — use for server/async state; pair with Valtio for UI-only state.
References
- Valtio GitHub: https://github.com/pmndrs/valtio
- Valtio docs: https://valtio.pmnd.rs/
- W3C WCAG 2.2: https://www.w3.org/TR/WCAG22/
- Apple Human Interface Guidelines: https://developer.apple.com/design/human-interface-guidelines
- Material Design: https://m3.material.io/