Getting Started with Valtio

Let's learn Valtio by building a simple to-do app in React and TypeScript. This guide will walk you through the core concepts and show you how to manage both simple and more complex state.

The To-Do App Example

1. Define the State with proxy

First, we'll define the shape of our state and create a reactive proxy store. This store will hold our list of todos and the current filter.

import { proxy } from 'valtio';

type Status = 'pending' | 'completed';
type Filter = Status | 'all';
type Todo = {
  description: string;
  status: Status;
  id: number;
};

export const store = proxy<{ filter: Filter; todos: Todo[] }>({
  filter: 'all',
  todos: [],
});

2. Read the State with useSnapshot

To display our todos in a React component, we use the useSnapshot hook. This creates an immutable snapshot of the state. The component will only re-render if the properties it accessed (todos and filter) have changed.

import { useSnapshot } from 'valtio';

const Todos = () => {
  const snap = useSnapshot(store);
  return (
    <ul>
      {snap.todos
        .filter(({ status }) => status === snap.filter || snap.filter === 'all')
        .map(({ description, status, id }) => {
          return (
            <li key={id}>
              <span data-status={status} className="description">
                {description}
              </span>
              <button className="remove" onClick={() => removeTodo(id)}>x</button>
            </li>
          );
        })}
    </ul>
  );
};

3. Mutate the State with Actions

To add, update, or delete todos, we directly mutate the store object. It's a common pattern to wrap these mutations in action functions.

Important: Always mutate the original store proxy, not the snap object returned by useSnapshot.

const addTodo = (description: string) => {
  store.todos.push({
    description,
    status: 'pending',
    id: Date.now(),
  });
};

const removeTodo = (id: number) => {
  const index = store.todos.findIndex((todo) => todo.id === id);
  if (index >= 0) {
    store.todos.splice(index, 1);
  }
};

const toggleDone = (id: number, currentStatus: Status) => {
  const nextStatus = currentStatus === 'pending' ? 'completed' : 'pending';
  const todo = store.todos.find((todo) => todo.id === id);
  if (todo) {
    todo.status = nextStatus;
  }
};

const setFilter = (filter: Filter) => {
  store.filter = filter;
};

Now you can wire up these actions to your UI components.


Complete Example: Simple To-Do App

Explore the full code for this basic To-Do app in the CodeSandbox below.

https://codesandbox.io/s/valtio-to-do-list-forked-6w9h3z

Mutating State Outside of Components

One of Valtio's powerful features is the ability to mutate state from anywhere, even outside of React components. Let's enhance our to-do app with a countdown timer for each task.

First, we'll add a timeLeft property to our Todo type.

type Todo = {
  description: string;
  status: Status;
  id: number;
  timeLeft: number; // Time in milliseconds
};

Next, we'll create a Countdown component that displays the remaining time for a todo. It takes the todo's index as a prop and creates a snapshot of that specific todo object for optimized re-renders.

import { useSnapshot } from 'valtio';
import { store } from './App';

export const Countdown = ({ index }: { index: number }) => {
  const snap = useSnapshot(store.todos[index]);
  // ... logic to format and display snap.timeLeft ...
};

Now for the magic. We can create a recursive countdown function in module scope (outside any component) that directly mutates the timeLeft property for a given todo.

const countdown = (index: number) => {
  const todo = store.todos[index];
  // Exit if todo is removed, completed, or overdue
  if (!todo || todo.status !== 'pending') return;

  // Handle timer expiration
  if (todo.timeLeft < 1000) {
    todo.timeLeft = 0;
    todo.status = 'overdue';
    return;
  }

  // Decrement time and schedule the next tick
  setTimeout(() => {
    todo.timeLeft -= 1000;
    countdown(index);
  }, 1000);
};

We can start this countdown from our addTodo action.

const addTodo = (description: string, deadline: Date) => {
  const now = Date.now();
  store.todos.push({
    description,
    status: 'pending',
    id: now,
    timeLeft: deadline.getTime() - now,
  });
  // Start the countdown for the newly added todo
  countdown(store.todos.length - 1);
};

This approach cleanly separates the timer logic from the UI, demonstrating how Valtio simplifies complex, asynchronous state management.

Complete Example: Countdown To-Do App

See the full implementation of the countdown feature in this CodeSandbox.

https://codesandbox.io/s/valtio-countdown-to-do-list-xkgmri