valtio
Manages state with Valtio using proxy-based reactivity, direct mutations, and automatic re-renders. Use when wanting mutable state syntax, fine-grained reactivity, or state management outside React components.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Valtio
Proxy-based state management that makes React state feel like plain JavaScript.
Quick Start
Install:
npm install valtio
Create state:
// store.ts
import { proxy } from 'valtio';
interface Store {
count: number;
users: User[];
filter: 'all' | 'active' | 'completed';
}
export const store = proxy<Store>({
count: 0,
users: [],
filter: 'all',
});
// Actions - mutate directly
export const increment = () => {
store.count++;
};
export const addUser = (user: User) => {
store.users.push(user);
};
Use in React:
import { useSnapshot } from 'valtio';
import { store, increment } from './store';
function Counter() {
// Only re-renders when count changes
const snap = useSnapshot(store);
return (
<div>
<p>Count: {snap.count}</p>
<button onClick={increment}>+1</button>
{/* Or mutate directly */}
<button onClick={() => store.count++}>+1</button>
</div>
);
}
Core Concepts
proxy() - Create Reactive State
import { proxy } from 'valtio';
// Simple state
const state = proxy({ count: 0, text: '' });
// Nested objects are automatically proxied
const store = proxy({
user: {
name: 'John',
settings: {
theme: 'dark',
notifications: true,
},
},
todos: [],
});
// Mutations work at any depth
store.user.settings.theme = 'light';
store.todos.push({ id: 1, text: 'Learn Valtio' });
useSnapshot() - Read in React
Returns a frozen, read-only snapshot that triggers re-renders only when accessed properties change.
import { useSnapshot } from 'valtio';
function UserProfile() {
const snap = useSnapshot(store);
// Only re-renders when user.name changes
return <p>{snap.user.name}</p>;
}
function TodoList() {
const snap = useSnapshot(store);
// Only re-renders when todos array changes
return (
<ul>
{snap.todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
Synchronous updates:
// For immediate renders (useful in tests)
const snap = useSnapshot(store, { sync: true });
Mutations - Write to Proxy
Always mutate the proxy, never the snapshot.
// Direct mutations
store.count++;
store.user.name = 'Jane';
// Array mutations
store.items.push(newItem);
store.items.splice(index, 1);
store.items[0].done = true;
// Object replacement
store.user = { ...store.user, name: 'Jane' };
// Delete properties
delete store.user.email;
Actions Pattern
// store/todos.ts
import { proxy } from 'valtio';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoStore {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
export const todoStore = proxy<TodoStore>({
todos: [],
filter: 'all',
});
// Actions - define alongside store
export const actions = {
addTodo(text: string) {
todoStore.todos.push({
id: crypto.randomUUID(),
text,
completed: false,
});
},
toggleTodo(id: string) {
const todo = todoStore.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo(id: string) {
const index = todoStore.todos.findIndex(t => t.id === id);
if (index >= 0) {
todoStore.todos.splice(index, 1);
}
},
clearCompleted() {
todoStore.todos = todoStore.todos.filter(t => !t.completed);
},
setFilter(filter: TodoStore['filter']) {
todoStore.filter = filter;
},
};
Computed Properties
Getters
const store = proxy({
firstName: 'John',
lastName: 'Doe',
get fullName() {
return `${this.firstName} ${this.lastName}`;
},
todos: [] as Todo[],
get completedCount() {
return this.todos.filter(t => t.completed).length;
},
get activeCount() {
return this.todos.length - this.completedCount;
},
});
// Usage
console.log(store.fullName); // 'John Doe'
derive() - Cross-Store Computations
import { proxy } from 'valtio';
import { derive } from 'derive-valtio';
const userStore = proxy({ name: 'John', role: 'admin' });
const settingsStore = proxy({ theme: 'dark' });
// Create derived state from multiple stores
const derived = derive({
greeting: (get) => `Hello, ${get(userStore).name}!`,
canEdit: (get) => get(userStore).role === 'admin',
});
// Attach derived properties to existing proxy
derive(
{
isDark: (get) => get(settingsStore).theme === 'dark',
},
{ proxy: userStore }
);
Subscriptions
subscribe() - React to Changes
import { subscribe } from 'valtio';
// Subscribe to all changes
const unsubscribe = subscribe(store, () => {
console.log('Store changed:', store);
});
// Subscribe to nested object
subscribe(store.user, () => {
console.log('User changed');
});
// Persist to localStorage
subscribe(store, () => {
localStorage.setItem('store', JSON.stringify(store));
});
// Cleanup
unsubscribe();
subscribeKey() - Watch Single Property
import { subscribeKey } from 'valtio/utils';
subscribeKey(store, 'count', (value) => {
console.log('Count is now:', value);
document.title = `Count: ${value}`;
});
watch() - Auto-tracking
import { watch } from 'valtio/utils';
const stop = watch((get) => {
// Automatically subscribes to accessed properties
console.log('User:', get(store).user.name);
console.log('Count:', get(store).count);
});
// Later
stop();
Utilities
ref() - Escape Proxy
Wrap values that shouldn't be proxied (DOM nodes, class instances, large data).
import { proxy, ref } from 'valtio';
const store = proxy({
// DOM node - don't proxy
canvas: ref(document.createElement('canvas')),
// Large dataset - don't proxy for performance
bigData: ref(hugeArray),
// Class instance - preserve prototype
date: ref(new Date()),
});
snapshot() - Get Immutable Copy
import { snapshot } from 'valtio';
const snap = snapshot(store);
// Useful for:
// - Sending to API
// - Logging
// - Comparison
console.log(JSON.stringify(snap));
// Deep equality check
if (snapshot(store) !== previousSnap) {
// State changed
}
proxySet() and proxyMap()
import { proxySet, proxyMap } from 'valtio/utils';
// Reactive Set
const selectedIds = proxySet<string>(['id1', 'id2']);
selectedIds.add('id3');
selectedIds.delete('id1');
selectedIds.has('id2'); // true
// Reactive Map
const users = proxyMap<string, User>([
['user1', { name: 'John' }],
]);
users.set('user2', { name: 'Jane' });
users.get('user1'); // { name: 'John' }
users.delete('user1');
devtools()
import { devtools } from 'valtio/utils';
// Connect to Redux DevTools
const unsub = devtools(store, {
name: 'MyApp Store',
enabled: process.env.NODE_ENV === 'development',
});
Async Actions
const store = proxy({
users: [] as User[],
loading: false,
error: null as string | null,
});
export async function fetchUsers() {
store.loading = true;
store.error = null;
try {
const response = await fetch('/api/users');
store.users = await response.json();
} catch (e) {
store.error = e instanceof Error ? e.message : 'Unknown error';
} finally {
store.loading = false;
}
}
// Can call from anywhere - not just React
fetchUsers();
Outside React
Valtio works without React.
import { proxy, subscribe, snapshot } from 'valtio/vanilla';
const state = proxy({ count: 0 });
// Subscribe to changes
subscribe(state, () => {
const snap = snapshot(state);
document.getElementById('count')!.textContent = String(snap.count);
});
// Update from anywhere
document.getElementById('btn')!.onclick = () => {
state.count++;
};
Testing
import { proxy, snapshot } from 'valtio';
import { store, actions } from './store';
describe('todoStore', () => {
beforeEach(() => {
// Reset state
store.todos = [];
store.filter = 'all';
});
it('adds todo', () => {
actions.addTodo('Test todo');
expect(store.todos).toHaveLength(1);
expect(store.todos[0].text).toBe('Test todo');
});
it('toggles todo', () => {
actions.addTodo('Test');
const id = store.todos[0].id;
actions.toggleTodo(id);
expect(store.todos[0].completed).toBe(true);
});
it('creates immutable snapshot', () => {
store.count = 5;
const snap = snapshot(store);
expect(snap.count).toBe(5);
expect(() => {
(snap as any).count = 10;
}).toThrow();
});
});
Common Patterns
Store Slices
// stores/user.ts
export const userStore = proxy({
user: null as User | null,
login(user: User) {
this.user = user;
},
logout() {
this.user = null;
},
});
// stores/cart.ts
export const cartStore = proxy({
items: [] as CartItem[],
add(item: CartItem) {
this.items.push(item);
},
});
// stores/index.ts - combine if needed
export { userStore } from './user';
export { cartStore } from './cart';
Form State
const formStore = proxy({
values: {
email: '',
password: '',
},
errors: {} as Record<string, string>,
touched: {} as Record<string, boolean>,
setField(field: string, value: string) {
this.values[field] = value;
this.touched[field] = true;
this.validate(field);
},
validate(field?: string) {
// Validation logic
},
});
Best Practices
- Read from snapshot, write to proxy - Never mutate snap
- Define actions as functions - Keep mutations organized
- Use ref() for non-reactive data - DOM nodes, large arrays
- Keep stores small - Split by domain/feature
- TypeScript interfaces - Define types for store shape
Common Mistakes
| Mistake | Fix |
|---|---|
| Mutating snapshot | Mutate proxy instead |
| Spreading proxy in render | Use snapshot values |
| Large objects without ref() | Wrap with ref() |
| Async in render | Move to action function |
Reference Files
- references/utils.md - Utility functions
- references/patterns.md - Advanced patterns
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-28
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!