Simplifying State Management in React with Valtio

Simplifying State Management in React with ValtioSimplifying State Management in React with Valtio

Dec 18, 2025 - 13 min

Luka Smiljić

Luka Smiljić

Software Engineer


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

PatternItemExample
Store{feature}StoreusersStore
Hookuse{Feature}StoreuseUsersStore
ActionsverbsfetchUsers, 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

Ready to talk?

Send a brief introduction to schedule a discovery call. The call focuses on your challenges and goals and outlines the first steps toward the right digital solution.

Simplifying State Management in React with Valtio | Workspace