Valtio: атомарный state management в React

30 октября 2026
Автор

Антон Ларичев

Что такое Valtio и зачем он нужен

Valtio — библиотека управления состоянием для React и vanilla JavaScript, построенная на основе нативного JavaScript-механизма Proxy. Она позволяет работать с состоянием напрямую, как с обычным объектом, без экшенов, редьюсеров и boilerplate-кода.

Главная идея — прозрачная реактивность: вы мутируете объект состояния напрямую, а Valtio автоматически отслеживает, какие части дерева состояния изменились, и перерисовывает только те компоненты, которые подписаны именно на эти данные.

Под «атомарностью» в контексте Valtio подразумевается гранулярная подписка на изменения. Компонент реагирует не на изменение всего store, а только на те поля, к которым он обратился внутри хука useSnapshot.

npm install valtio

Курс по теме

Курс React и Redux Toolkit с нуля

40 000+ студентов · рейтинг 4.8 · гарантия возврата 30 дней

Базовый пример: создание store и подписка

В Valtio состояние создаётся функцией proxy. Она оборачивает обычный объект в Proxy, который перехватывает все операции чтения и записи.

import { proxy } from 'valtio'

const counterStore = proxy({
  count: 0,
  step: 1,
})

Для чтения состояния в компоненте используется хук useSnapshot. Он возвращает иммутабельный снапшот текущего состояния и подписывает компонент только на те поля, к которым произошло обращение во время рендера.

import { useSnapshot } from 'valtio'

function Counter() {
  const snap = useSnapshot(counterStore)

  return (
    <div>
      <p>Счётчик: {snap.count}</p>
      <button onClick={() => (counterStore.count += counterStore.step)}>
        Увеличить
      </button>
    </div>
  )
}

Обратите внимание: мутация выполняется напрямую на объекте counterStore, а не через снапшот snap (снапшот заморожен). Это принципиальное отличие от Redux-подхода.

Вложенные объекты и массивы

Valtio отслеживает изменения рекурсивно. Вложенные объекты и массивы автоматически оборачиваются в Proxy, поэтому гранулярная реактивность работает на любой глубине дерева.

import { proxy } from 'valtio'

const todoStore = proxy({
  items: [
    { id: 1, text: 'Изучить Valtio', done: false },
    { id: 2, text: 'Написать проект', done: false },
  ],
  filter: 'all' as 'all' | 'active' | 'done',
})

// Прямые мутации работают корректно
function toggleTodo(id: number) {
  const item = todoStore.items.find((i) => i.id === id)
  if (item) {
    item.done = !item.done
  }
}

function addTodo(text: string) {
  todoStore.items.push({
    id: Date.now(),
    text,
    done: false,
  })
}

function removeTodo(id: number) {
  const index = todoStore.items.findIndex((i) => i.id === id)
  if (index !== -1) {
    todoStore.items.splice(index, 1)
  }
}

Компонент, который рендерит только filter, не перерисуется при добавлении нового элемента в items — именно это и называется атомарной подпиской.

function FilterPanel() {
  const snap = useSnapshot(todoStore)
  // Этот компонент перерисуется только при смене snap.filter
  return (
    <div>
      {(['all', 'active', 'done'] as const).map((f) => (
        <button
          key={f}
          onClick={() => (todoStore.filter = f)}
          style={{ fontWeight: snap.filter === f ? 'bold' : 'normal' }}
        >
          {f}
        </button>
      ))}
    </div>
  )
}

Вычисляемые значения с derive

Для производных (вычисляемых) значений Valtio предоставляет функцию derive. Она создаёт новый proxy-объект, поля которого автоматически пересчитываются при изменении зависимостей.

import { proxy } from 'valtio'
import { derive } from 'valtio/utils'

const cartStore = proxy({
  items: [] as Array<{ name: string; price: number; qty: number }>,
  discount: 0,
})

const cartDerived = derive({
  subtotal: (get) =>
    get(cartStore).items.reduce((sum, item) => sum + item.price * item.qty, 0),

  total: (get) => {
    const { subtotal } = get(cartDerived)
    const { discount } = get(cartStore)
    return subtotal * (1 - discount / 100)
  },

  itemCount: (get) =>
    get(cartStore).items.reduce((sum, item) => sum + item.qty, 0),
})

derive ленив: вычисление запускается только тогда, когда к полю обращается компонент. Если компонент не читает total, это значение не пересчитывается.

function CartSummary() {
  const snap = useSnapshot(cartDerived)

  return (
    <div>
      <p>Товаров: {snap.itemCount}</p>
      <p>Итого: {snap.total.toFixed(2)} ₽</p>
    </div>
  )
}

Асинхронные операции

Valtio не диктует способ работы с async-кодом. Вы просто пишете асинхронную функцию и обновляете store по завершении.

import { proxy } from 'valtio'

const usersStore = proxy({
  list: [] as User[],
  loading: false,
  error: null as string | null,
})

async function fetchUsers() {
  usersStore.loading = true
  usersStore.error = null

  try {
    const response = await fetch('/api/users')
    if (!response.ok) throw new Error('Ошибка загрузки')
    const data: User[] = await response.json()
    usersStore.list = data
  } catch (err) {
    usersStore.error = err instanceof Error ? err.message : 'Неизвестная ошибка'
  } finally {
    usersStore.loading = false
  }
}

Valtio корректно обрабатывает асинхронные значения в снапшотах. Если поле содержит Promise, useSnapshot приостановит рендер компонента (интеграция с React Suspense):

const asyncStore = proxy({
  data: fetch('/api/config').then((r) => r.json()),
})

// В компоненте с Suspense-обёрткой:
function Config() {
  const snap = useSnapshot(asyncStore)
  // snap.data автоматически «разворачивает» промис через Suspense
  return <pre>{JSON.stringify(snap.data, null, 2)}</pre>
}

Подписка вне компонентов: subscribe

Для подписки на изменения состояния вне React-компонентов (например, для сайд-эффектов, логирования или синхронизации с localStorage) используется функция subscribe.

import { subscribe } from 'valtio'

// Подписка на весь store
const unsubscribe = subscribe(counterStore, () => {
  console.log('Store изменился:', counterStore)
})

// Подписка на конкретное поле
const unsubscribeCount = subscribeKey(counterStore, 'count', (value) => {
  localStorage.setItem('counter', String(value))
})

// Отписка
unsubscribe()
unsubscribeCount()

subscribeKey из valtio/utils — более гранулярный вариант: callback вызывается только при изменении конкретного ключа верхнего уровня.

Паттерн: разделение store и actions

Хорошая практика — выносить мутации в отдельные функции-действия, а не писать их прямо в обработчиках событий. Это улучшает тестируемость и читаемость кода.

// store/auth.ts
import { proxy } from 'valtio'

interface AuthState {
  user: User | null
  token: string | null
  loading: boolean
}

export const authStore = proxy<AuthState>({
  user: null,
  token: null,
  loading: false,
})

// actions/auth.ts
export async function login(email: string, password: string) {
  authStore.loading = true
  try {
    const { user, token } = await authApi.login(email, password)
    authStore.user = user
    authStore.token = token
    localStorage.setItem('token', token)
  } finally {
    authStore.loading = false
  }
}

export function logout() {
  authStore.user = null
  authStore.token = null
  localStorage.removeItem('token')
}

export function restoreSession() {
  const token = localStorage.getItem('token')
  if (token) {
    authStore.token = token
  }
}
// Использование в компоненте
import { useSnapshot } from 'valtio'
import { authStore } from './store/auth'
import { login, logout } from './actions/auth'

function AuthButton() {
  const { user, loading } = useSnapshot(authStore)

  if (loading) return <span>Загрузка...</span>
  if (user) return <button onClick={logout}>Выйти ({user.name})</button>

  return <button onClick={() => login('user@example.com', 'pass')}>Войти</button>
}

Работа с DevTools

Valtio поддерживает интеграцию с Redux DevTools через утилиту devtools из пакета valtio/utils.

import { devtools } from 'valtio/utils'

const store = proxy({ count: 0 })

// Второй аргумент — имя store в DevTools
const unsub = devtools(store, { name: 'CounterStore', enabled: true })

После подключения все мутации store будут отображаться в панели Redux DevTools браузера с возможностью путешествия во времени.

Valtio и TypeScript

Valtio написан на TypeScript и предоставляет полный вывод типов из переданного объекта состояния. Никаких дополнительных обобщений не требуется в большинстве случаев.

import { proxy, useSnapshot } from 'valtio'

interface Product {
  id: number
  name: string
  price: number
  stock: number
}

interface ShopState {
  products: Product[]
  selectedId: number | null
  searchQuery: string
}

const shopStore = proxy<ShopState>({
  products: [],
  selectedId: null,
  searchQuery: '',
})

// snap типизирован как DeepReadonly<ShopState>
function ProductSearch() {
  const snap = useSnapshot(shopStore)

  const filtered = snap.products.filter((p) =>
    p.name.toLowerCase().includes(snap.searchQuery.toLowerCase())
  )

  return (
    <div>
      <input
        value={snap.searchQuery}
        onChange={(e) => (shopStore.searchQuery = e.target.value)}
        placeholder="Поиск товаров"
      />
      {filtered.map((p) => (
        <div key={p.id} onClick={() => (shopStore.selectedId = p.id)}>
          {p.name} — {p.price} ₽
        </div>
      ))}
    </div>
  )
}

Сравнение с Zustand и Jotai

Все три библиотеки относятся к «лёгким» менеджерам состояния, но имеют разные подходы.

Zustand требует явного определения store через функцию и использует иммутабельные обновления через set. Хорошо подходит, если команда привыкла к Redux-подобному стилю без его verbosity.

Jotai реализует атомарную модель снизу вверх: состояние делится на минимальные атомы, которые можно комбинировать. Отличный выбор для сложных взаимозависимостей между частями состояния.

Valtio делает ставку на максимальную простоту и привычный синтаксис мутаций. Proxy-механизм позволяет писать код без дополнительных абстракций. Подходит, когда нужна скорость разработки и минимальный boilerplate.

Критерий Valtio Zustand Jotai
Синтаксис обновления Прямая мутация set() с иммутабельностью setAtom()
Гранулярность подписки Автоматическая Ручной selector По атому
Вне-React использование subscribe getState/setState store.get/set
Кривая обучения Низкая Низкая Средняя
Отладка Redux DevTools Redux DevTools DevTools плагин

Типичные ошибки при работе с Valtio

Мутация снапшота вместо store. Снапшот, возвращаемый useSnapshot, заморожен (Object.freeze). Попытка его изменить приведёт к ошибке в strict mode.

// Неправильно:
const snap = useSnapshot(store)
snap.count++ // TypeError в strict mode

// Правильно:
store.count++

Деструктуризация снапшота на верхнем уровне. При деструктуризации примитивных значений теряется связь с proxy, но для снапшота это нормально — он уже иммутабелен. Проблема возникает, если деструктурировать сам store для последующего чтения:

// Чтение через деструктуризацию — безопасно для отображения:
const { count, step } = useSnapshot(store)

// Деструктуризация store для мутации — нормально:
const { items } = store
items.push({ id: 1, text: 'task' }) // OK, items — тот же proxy-объект

Замена объектов вместо мутации. Если заменить весь вложенный объект новым, подписки на конкретные поля этого объекта могут не среагировать корректно. Предпочтительнее мутировать поля по одному или использовать Object.assign.

// Менее предпочтительно:
store.user = { ...store.user, name: 'New Name' }

// Предпочтительно:
store.user.name = 'New Name'

Итог

Valtio предлагает радикально простой API: proxy для создания состояния, useSnapshot для чтения в компонентах, прямые мутации для обновления. Встроенная атомарная реактивность через Proxy устраняет необходимость в ручных селекторах — библиотека сама отслеживает, какие данные читает каждый компонент.

Библиотека хорошо подходит для средних и больших приложений, где важна производительность рендеринга, но не хочется тратить время на настройку сложной инфраструктуры управления состоянием.

Для более глубокого погружения в работу с состоянием в React, компонентную архитектуру и современные паттерны — смотрите курс на PurpleSchool: React — полный курс для разработчиков

useState в React что это и как использоватьСтрелочка вправо

Постройте личный план изучения React до уровня Middle — бесплатно!

React — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по React

React тестирование с Vitest
Zustand: продвинутые паттерны, persist middleware и slicesUncontrolled Components: когда DOM управляет даннымиБезопасность в React: защита от XSS, CSRF и утечек данныхRender Props: гибкое управление рендерингом в ReactРефакторинг React-кода: техники и лучшие практикиReact с WebSocket — реалтайм в компонентахReact Native New Architecture: Fabric и JSIReact Native приложение с нуля до публикации в сторахПрофилирование React: как найти и устранить узкие местаЧастичное применение: как создавать компоненты без лишнего кодаИменование компонентов в React: соглашения и лучшие практикиЛенивая загрузка: как ускорить React-приложение в разыОшибки гидратации в React и способы их устраненияHOC в React: мастерство композиции компонентовАрхитектура больших React приложений: Feature-Sliced DesignuseMemo: как спасти производительность от тяжелых вычисленийError Boundaries: создаем надежные React-приложенияКонтролируемые компоненты в React: полный контроль над формамиCompound Components в React: создаем гибкие компоненты с мощным APIДокументирование компонентов в React: Storybook, JSDoc и READMEКомпозиция компонентов в React: строим гибкие интерфейсыКомментирование кода в React: когда и как писать комментарииCode Splitting в React: как уменьшить бандл и ускорить загрузку приложенияАсинхронные компоненты в React: новый стандарт работы с даннымиДоступность (a11y) в React: ARIA, семантика и клавиатурная навигация
Zustand — управление состоянием в ReactZod - валидация с TypeScriptYup - валидация схемXState - конечные автоматыТемизация в ReactТестирование хуковTailwind CSS с ReactSWR - библиотека для запросовStyled Components — стилизация через JSStorybook - документация компонентовSnapshots тестированиеRTK Query - работа с APIRecoil — библиотека управления состоянием от FacebookRedux Toolkit - современный ReduxВиртуализация списков с react-window: как отображать тысячи элементов без лаговReact Toastify - уведомления в ReactReact Testing LibraryСоздание таблиц в React гайд по react-tableReact Spring - анимацииРабота с формами и селектами в ReactReact Query (TanStack Query) - работа с серверомПлагины в React что это и как их использоватьReact PDF - работа с PDF файламиОбзор популярных библиотек для ReactReact Icons - библиотека иконок для ReactReact Hook Form — валидация форм в ReactReact Dropzone — загрузка файловПодключение Bootstrap к React-приложениюReact Beautiful DnD - перетаскивание элементовАнимация при монтировании компонентов в ReactМокирование APIMobX — реактивное управление состоянием в ReactМикрофронтенды с React (micro-frontends)Загрузка и индикаторыАнимация списков в ReactJotai - атомарное состояниеБесконечная прокруткаFramer Motion - библиотека анимацийEmotion — библиотека CSS-in-JSДинамические стили в ReactE2E тестирование с CypressCSSTransition - переходыCSS-in-JS — плюсы и минусыКонтекст vs Redux — когда что использоватьИспользование Chart.js в ReactAxios с ReactТестирование асинхронных компонентовОбработка ошибок API
Valtio: атомарный state management в ReactuseState в React что это и как использоватьuseTransition - плавные переходы между состояниямиuseSyncExternalStore — работа с внешними сторамиuseRef в React — создание ссылок на DOM и значенияuseOptimistic — оптимистичные обновления UIuseLayoutEffect в React — эффект до отрисовкиuseInsertionEffect — внедрение стилей до мутаций DOMuseImperativeHandle в React — настройка ref дочернего компонентаuseId — генерация уникальных идентификаторовuseFormStatus - отслеживание статуса отправки формыuseDeferredValue — отложенное обновление состоянияuseDebugValue — отладка кастомных хуковuseCallback в React — мемоизация функцийuseReducer — альтернатива useState для сложной логикиuseMemo в React: как и когда оптимизировать тяжелые вычисленияХук use() в React 19useEffect в React что это и как использоватьuseContext — работа с контекстом в ReactuseCallback в React — мемоизация функций и оптимизация ре-рендеровuseActionState в React 19React Stale Closures — устаревшие замыкания в хукахОптимизация рендеринга в React: от теории к глубокой практикеЧто такое useRef и как его применять в ReactКак и зачем использовать React HooksУправление состоянием в React через ContextНовые возможности React 19Как предотвратить лишние ре-рендеры в React: полное руководствоuseMemo vs useCallback: подробное руководство по мемоизации в ReactПравила хуков — правила использованияflushSync — синхронные обновления состояния в ReactuseEffect vs useLayoutEffect: в чём разница и какой хук выбрать?Кастомные хуки в React — создание собственных хуковuseState продвинутое использование в React
Transition API — плавные обновления интерфейса в ReactReact Suspense — приостановка рендераStrictMode в React — как находить ошибки на этапе разработкиСерверные компоненты React (RSC) — подробный разбор и практикаКак работает рендеринг в ReactЧто такое props в React и как их правильно использоватьКак работает JSX связка React и HTMLЧто такое React.js и как его использоватьКак использовать элементы в ReactКак использовать React DOM в проектеЧто такое компоненты в React и как их применятьРабота с children в ReactПорталы в React: рендер компонентов вне иерархии DOMFragment в React: группировка элементов без лишних узлов DOMCSS Modules в ReactConcurrent Mode — конкурентный режим в React
Открыть базу знаний

Лучшие курсы по теме

изображение курса

React и Redux Toolkit

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий