Managing state in React can often make things more complicated than they need to be. You need boilerplate for Redux. The Context API causes problems with re-rendering. It takes a lot of time to learn MobX. Valtio does things differently: it uses direct mutations with automatic reactivity.
This guide tells you everything you need to know to start using Valtio in real-world apps.
What is Valtio?
Daishi Kato, who also made Zustand and Jotai, made Valtio, a 3KB proxy-based state management library. It automatically keeps track of changes to the state using JavaScript Proxy objects.
The main idea is simple:
import { proxy, useSnapshot } from 'valtio';
// Make the state reactive
const state = proxy({ count: 0, user: null });
// Change directly—no actions, no dispatch
state.count++;
state.user = { name: 'John' };
No reducers. No action creators. No dispatch functions. You change the state directly, and Valtio takes care of everything else.
Installation
npm install valtio
# or
yarn add valtio
It comes with TypeScript types. It works with React 16.8 and later, Next.js, and React Native.
Two Core APIs: proxy and useSnapshot
There are two main things that Valtio does. If you learn these, you'll be able to handle 90% of the situations.
proxy – Make a Reactive State
The proxy function puts your state object in a wrapper:
import { proxy } from 'valtio';
interface AppState {
user: User | null;
theme: 'light' | 'dark';
notifications: Notification[];
}
export const appState = proxy<AppState>({
user: null,
theme: 'light',
notifications: [],
});
Using TypeScript interfaces lets you use autocomplete and find errors before the code runs.
useSnapshot – Read State in Components
useSnapshot lets components get to state. This hook makes the component listen for changes:
import { useSnapshot } from 'valtio';
import { appState } from './store';
function Header() {
const snap = useSnapshot(appState);
return (
<header>
<span>Hello, {snap.user?.name}</span>
<span>{snap.notifications.length} new notifications</span>
</header>
);
}
You can't change the snapshot. Changes happen on the proxy, not on the snapshot.
Changing the State
Change the proxy object directly from any location:
// In event handlers
function handleLogin(user: User) {
appState.user = user;
}
// In async functions
async function fetchNotifications() {
const data = await api.getNotifications();
appState.notifications = data;
}
// Toggle values
function toggleTheme() {
appState.theme = appState.theme === 'light' ? 'dark' : 'light';
}
Project Structure
To keep codebases clean, separate stores from actions:
src/valtio/
├── users/
│ ├── users.store.ts # State definition
│ └── users.actions.ts # Mutation functions
├── auth/
│ ├── auth.store.ts
│ └── auth.actions.ts
└── global/
├── global.store.ts
└── global.actions.ts
This pattern works well with larger projects and keeps related code together.
Full Example: Users Store
This is a store pattern that is ready for production:
// src/valtio/users/users.store.ts
import { proxy, useSnapshot } from 'valtio';
interface UsersStore {
users: User[];
selectedUser?: User;
isLoading: boolean;
error?: string;
modalOpen: boolean;
}
export const usersStore = proxy<UsersStore>({
users: [],
selectedUser: undefined,
isLoading: false,
error: undefined,
modalOpen: false,
});
export const useUsersStore = () => useSnapshot(usersStore);
// src/valtio/users/users.actions.ts
import { UsersService } from '@/services';
import { usersStore } from './users.store';
export async function fetchUsers() {
usersStore.isLoading = true;
usersStore.error = undefined;
try {
usersStore.users = await UsersService.getAll();
} catch (e) {
usersStore.error = 'Could not load users';
} finally {
usersStore.isLoading = false;
}
}
export function selectUser(user: User) {
usersStore.selectedUser = user;
}
export function toggleModal() {
usersStore.modalOpen = !usersStore.modalOpen;
}
export function clearSelection() {
usersStore.selectedUser = undefined;
}
How to use in components:
import { useEffect } from 'react';
import { fetchUsers, selectUser, toggleModal } from '@/valtio/users/users.actions';
import { useUsersStore } from '@/valtio/users/users.store';
function UsersPage() {
const { users, isLoading, error, modalOpen } = useUsersStore();
useEffect(() => {
fetchUsers();
}, []);
if (isLoading) return <Loading />;
if (error) return <Error message={error} />;
return (
<div>
<button onClick={toggleModal}>Add User</button>
{users.map(user => (
<UserCard key={user.id} user={user} onClick={() => selectUser(user)} />
))}
{modalOpen && <UserModal />}
</div>
);
}
See how actions are called directly without using hooks or dispatch? You don't need useSnapshot for components that only cause actions.
Global Store: Toast Alerts
A global store takes care of issues that affect the whole app:
// src/valtio/global/global.store.ts
import { proxy, useSnapshot } from 'valtio';
interface Toast {
id: string;
type: 'success' | 'error' | 'info';
message: string;
}
interface GlobalStore {
toasts: Toast[];
}
export const globalStore = proxy<GlobalStore>({
toasts: [],
});
export const useGlobalStore = () => useSnapshot(globalStore);
// src/valtio/global/global.actions.ts
import { globalStore } from './global.store';
export function showToast(type: Toast['type'], message: string) {
const id = crypto.randomUUID();
globalStore.toasts.push({ id, type, message });
// Remove automatically after 3 seconds
setTimeout(() => {
const index = globalStore.toasts.findIndex(t => t.id === id);
if (index > -1) globalStore.toasts.splice(index, 1);
}, 3000);
}
export function dismissToast(id: string) {
const index = globalStore.toasts.findIndex(t => t.id === id);
if (index > -1) globalStore.toasts.splice(index, 1);
}
You can use this anywhere in your app:
import { showToast } from '@/valtio/global/global.actions';
// After saving successfully
showToast('success', 'Changes saved!');
// After an error
showToast('error', 'Something went wrong');
This pattern works well with Material-UI when making UI parts.
Valtio vs Redux: A Comparison
The same counter in Redux Toolkit:
// Redux: 25+ lines
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => {
state.value++;
},
decrement: state => {
state.value--;
},
},
});
const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
// Component needs useSelector + useDispatch
const count = useSelector(state => state.counter.value);
const dispatch = useDispatch();
dispatch(increment());
The same counter in Valtio:
// Valtio: 8 lines
const counter = proxy({ value: 0 });
const increment = () => counter.value++;
const decrement = () => counter.value--;
// Component
const snap = useSnapshot(counter);
// snap.value, increment(), decrement()
Same result, less code.
Saving State to LocalStorage
import { proxy, subscribe } from 'valtio';
const STORAGE_KEY = 'app_settings';
// Load from storage
const saved = localStorage.getItem(STORAGE_KEY);
const initial = saved ? JSON.parse(saved) : { theme: 'light', language: 'en' };
export const settingsStore = proxy(initial);
// Auto-save when changes are made
subscribe(settingsStore, () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settingsStore));
});
This pattern saves user preferences across sessions for apps that support more than one language.
Using Valtio with Next.js
Valtio works with Next.js, but SSR needs some work:
// app/users/page.tsx (Server Component)
import { UsersClient } from './UsersClient';
export default async function UsersPage() {
const users = await fetchUsers(); // Server fetch
return <UsersClient initialUsers={users} />;
}
// app/users/UsersClient.tsx
'use client'
import { useEffect } from 'react'
import { usersStore, useUsersStore } from '@/valtio/users/users.store'
export function UsersClient({ initialUsers }) {
const { users } = useUsersStore()
useEffect(() => {
usersStore.users = initialUsers // Hydrate on mount
}, [initialUsers])
return (
// Your JSX here
)
}
Valtio and next-intl work well together for Next.js translations.
Common Mistakes
Changing the Snapshot
// Wrong: snapshot is read-only
const snap = useSnapshot(store);
snap.value = 'new'; // Error!
// Correct: change the proxy
store.value = 'new';
Creating a Proxy Inside a Component
// Wrong—makes a new proxy for each render
function Component() {
const state = proxy({ count: 0 }) // Don't!
}
// Correct—define outside
const state = proxy({ count: 0 })
function Component() {
const snap = useSnapshot(state)
}
Accessing Proxy Directly in JSX
// Wrong—won't re-render
return <div>{store.name}</div>
// Correct—use snapshot
const snap = useSnapshot(store)
return <div>{snap.name}</div>
Best Practices
Naming Rules
| Pattern | Item | Example |
|---|---|---|
| Store | {feature}Store | usersStore |
| Hook | use{Feature}Store | useUsersStore |
| Actions | verbs | fetchUsers, toggleModal |
Store Structure
interface FeatureStore {
// Data
items: Item[];
selected?: Item;
// Loading/Error
isLoading: boolean;
error?: string;
// UI state
modalOpen: boolean;
}
File Structure
feature/
├── feature.store.ts # State only
└── feature.actions.ts # All mutations
Combining Valtio with Markdown-based pages is the easiest way to keep things simple for content sites.
Authentication Store
Most apps have some form of authentication. Here's a pattern you can use again:
// src/valtio/auth/auth.store.ts
import { proxy, useSnapshot } from 'valtio';
interface AuthStore {
user: User | null;
token: string | null;
isLoading: boolean;
}
export const authStore = proxy<AuthStore>({
user: null,
token: localStorage.getItem('token'),
isLoading: true,
});
export const useAuthStore = () => useSnapshot(authStore);
// src/valtio/auth/auth.actions.ts
import { AuthService } from '@/services';
import { authStore } from './auth.store';
export async function login(email: string, password: string) {
authStore.isLoading = true;
try {
const { user, token } = await AuthService.login(email, password);
authStore.user = user;
authStore.token = token;
localStorage.setItem('token', token);
return true;
} catch {
return false;
} finally {
authStore.isLoading = false;
}
}
export function logout() {
authStore.user = null;
authStore.token = null;
localStorage.removeItem('token');
}
export async function checkAuth() {
if (!authStore.token) {
authStore.isLoading = false;
return;
}
try {
authStore.user = await AuthService.me();
} catch {
logout();
} finally {
authStore.isLoading = false;
}
}
To restore sessions, call checkAuth() when the app starts.
Subscribing Outside of React
Valtio lets you subscribe to state outside of components. Good for logging, syncing, or analytics:
import { subscribe } from 'valtio';
// Keep an eye on all changes to the cart
subscribe(cartStore, () => {
analytics.track('cart_updated', {
items: cartStore.items.length,
total: cartStore.total,
});
});
Use subscribeKey to get information about certain properties:
import { subscribeKey } from 'valtio/utils';
subscribeKey(authStore, 'user', user => {
if (user) {
analytics.identify(user.id);
}
});
Computed Values with derive
Use derive to get values from state:
import { proxy } from 'valtio';
import { derive } from 'valtio/utils';
const cartStore = proxy({
items: [],
taxRate: 0.08,
});
const cartDerived = derive({
subtotal: get => get(cartStore).items.reduce((sum, item) => sum + item.price * item.quantity, 0),
tax: get => get(cartDerived).subtotal * get(cartStore).taxRate,
total: get => get(cartDerived).subtotal + get(cartDerived).tax,
});
When dependencies change, derived values update on their own.
Testing Stores
Testing Valtio is easy because stores are just simple objects:
import { clearSelection, selectUser } from './users.actions';
import { usersStore } from './users.store';
describe('UsersStore', () => {
beforeEach(() => {
// Reset state before each test
usersStore.users = [];
usersStore.selectedUser = undefined;
});
it('selects a user', () => {
const user = { id: '1', name: 'John' };
selectUser(user);
expect(usersStore.selectedUser).toEqual(user);
});
it('clears the selection', () => {
usersStore.selectedUser = { id: '1', name: 'John' };
clearSelection();
expect(usersStore.selectedUser).toBeUndefined();
});
});
As always, use Jest to make fake API calls.
Performance Tips
Break up big stores into smaller ones that are specific to certain areas:
// Good: Focused stores
const usersStore = proxy({ users: [], selected: null })
const productsStore = proxy({ products: [], categories: [] })
const cartStore = proxy({ items: [], total: 0 })
// Avoid: One huge store
const appStore = proxy({ users: [], products: [], cart: [], ... })
To limit re-renders, destructure snapshots:
// The component only re-renders when users change
const { users } = useSnapshot(usersStore);
// vs. re-rendering on any store change
const snap = useSnapshot(usersStore);
When to Use Valtio
Good fit:
- Applications that are small to medium
- Teams that don't want a lot of boilerplate
- Projects where Redux seems like too much work
- Quick prototyping
Consider alternatives if:
- You need Redux DevTools a lot
- Your team already knows Redux well
- You need a middleware ecosystem







