valtio の使い方 — Proxyベースの直感的なReact状態管理
一言でいうと
valtioは、JavaScriptのProxyを活用して普通のオブジェクト操作だけで状態管理を実現するライブラリです。ミュータブルな書き味でありながら、Reactコンポーネントのレンダリング最適化を自動で行ってくれます。
どんな時に使う?
- Reduxのボイラープレートに疲れた時 — action/reducer/dispatchなしに、オブジェクトを直接変更するだけで状態管理したい場合
- コンポーネント外からも状態を操作したい時 — WebSocket受信ハンドラやsetIntervalなど、React外のロジックから自然に状態を更新したい場合
- レンダリング最適化を自動でやりたい時 — アクセスしたプロパティだけを追跡し、不要な再レンダリングを自動で防ぎたい場合
インストール
# npm
npm install valtio
# yarn
yarn add valtio
# pnpm
pnpm add valtio
valtio の基本的な使い方
valtioの基本は3ステップです。proxyで状態を作り、直接変更し、useSnapshotで読み取る。これだけです。
import { proxy, useSnapshot } from 'valtio'
// 1. 状態を作る
const state = proxy({
count: 0,
text: 'hello',
})
// 2. どこからでも変更できる(React外でもOK)
const increment = () => {
++state.count
}
// 3. コンポーネントでスナップショットを読む
function Counter() {
const snap = useSnapshot(state)
// snap.count の変更時のみ再レンダリング(snap.text の変更では再レンダリングしない)
return (
<div>
<p>Count: {snap.count}</p>
<button onClick={increment}>+1</button>
</div>
)
}
重要な原則: レンダリング(JSX内)では snap から読み取り、変更は state に対して行います。
よく使うAPI
1. proxy — 状態オブジェクトの作成
import { proxy } from 'valtio'
interface AppState {
user: { name: string; age: number } | null
todos: { id: number; title: string; done: boolean }[]
loading: boolean
}
const appState = proxy<AppState>({
user: null,
todos: [],
loading: false,
})
// ネストしたオブジェクトも自動的にProxyになる
appState.user = { name: 'Taro', age: 30 }
appState.todos.push({ id: 1, title: 'Buy milk', done: false })
2. useSnapshot — Reactコンポーネントでの状態読み取り
import { useSnapshot } from 'valtio'
function TodoList() {
const snap = useSnapshot(appState)
return (
<ul>
{snap.todos.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.title}
</span>
<button
onClick={() => {
// 変更は元の state に対して行う
const target = appState.todos.find((t) => t.id === todo.id)
if (target) target.done = !target.done
}}
>
Toggle
</button>
</li>
))}
</ul>
)
}
// sync オプション: input要素のようにバッチングを無効にしたい場合
function TextInput() {
const snap = useSnapshot(appState, { sync: true })
return (
<input
value={snap.user?.name ?? ''}
onChange={(e) => {
if (appState.user) appState.user.name = e.target.value
}}
/>
)
}
3. subscribe — 状態変更の購読
import { subscribe } from 'valtio'
import { subscribeKey } from 'valtio/utils'
// 状態全体の変更を購読
const unsubscribe = subscribe(appState, () => {
console.log('State changed:', appState)
})
// ネストしたオブジェクトだけを購読
subscribe(appState.todos, () => {
console.log('Todos changed:', appState.todos)
})
// 特定のキーだけを購読(プリミティブ値に便利)
subscribeKey(appState, 'loading', (value) => {
console.log('Loading changed to:', value)
})
// 購読解除
unsubscribe()
4. ref — Proxy化を避けるオブジェクトの保持
DOM要素やサードパーティのインスタンスなど、Proxyで包みたくないオブジェクトに使います。
import { proxy, ref } from 'valtio'
const editorState = proxy({
content: '',
// DOM要素やクラスインスタンスはrefで包む
canvasElement: ref<HTMLCanvasElement | null>(null),
// 大きなオブジェクトでトラッキング不要なものにも有効
cachedData: ref({ huge: 'object', that: 'should not be proxied' }),
})
5. watch / devtools — ユーティリティ
import { watch, devtools } from 'valtio/utils'
// watch: 使用した状態を自動購読(computed的な用途に)
const stop = watch((get) => {
// get() でアクセスした proxy を自動的に購読する
const { count } = get(appState)
console.log('Count is now:', count)
})
// 不要になったら停止
stop()
// devtools: Redux DevTools Extension との連携
const unsubDevtools = devtools(appState, {
name: 'App State',
enabled: process.env.NODE_ENV === 'development',
})
類似パッケージとの比較
| 特徴 | valtio | Zustand | Jotai | MobX |
|---|---|---|---|---|
| パラダイム | Proxy (mutable風) | Flux (immutable) | Atomic | Proxy (mutable) |
| バンドルサイズ | ~3KB | ~1KB | ~3KB | ~16KB |
| 学習コスト | 低い | 低い | 中程度 | 中〜高 |
| レンダリング最適化 | 自動(プロパティ追跡) | セレクタで手動 | 原子単位で自動 | 自動 |
| React外での利用 | ○ (valtio/vanilla) | ○ | △ | ○ |
| ボイラープレート | 最小 | 少ない | 少ない | やや多い |
| TypeScript | 良好 | 良好 | 良好 | 良好 |
| 開発元 | pmndrs | pmndrs | pmndrs | Michel Weststrate |
Zustand・Jotai・valtioは同じpmndrsコミュニティが開発しています。ミュータブルな書き味が好みならvaltio、シンプルなストアならZustand、原子的な状態管理ならJotaiが適しています。
注意点・Tips
⚠️ snap に書き込まない
useSnapshot が返すオブジェクトはフリーズされた読み取り専用です。変更は必ず元の proxy オブジェクトに対して行ってください。
// ❌ NG
const snap = useSnapshot(state)
snap.count++ // エラーまたは無視される
// ✅ OK
state.count++
⚠️ this の使用を避ける
オブジェクト内のメソッドで this を使うと、スナップショット経由で呼び出した際に問題が起きます。アロー関数で直接 state を参照するのが安全です。
// ❌ NG: thisはsnap経由だとフリーズされたオブジェクトを指す
const state = proxy({
count: 0,
inc() { ++this.count },
})
// ✅ OK: アロー関数でstateを直接参照
const state = proxy({
count: 0,
inc: () => { ++state.count },
})
⚠️ TypeScriptでスナップショットの型が厳しすぎる場合
useSnapshot の戻り値はDeepReadonly型になります。既存の型と合わない場合は型定義を緩和できます。
// 必要に応じてモジュール拡張で型を緩和
declare module 'valtio' {
function useSnapshot<T extends object>(p: T): T
}
💡 ESLintプラグインの導入を推奨
snap と state の使い分けミスを防ぐために、eslint-plugin-valtio の導入を強くおすすめします。
npm install -D eslint-plugin-valtio
💡 Vanilla JS でも使える
Reactに依存しない valtio/vanilla からインポートすれば、Node.jsやVanilla JSプロジェクトでも利用可能です。
import { proxy, subscribe, snapshot } from 'valtio/vanilla'
💡 <input> には sync: true を使う
デフォルトではstate変更はバッチ処理されるため、テキスト入力で文字が飛ぶ問題が起きることがあります。useSnapshot(state, { sync: true }) で解決できます。
まとめ
valtioは「普通のJavaScriptオブジェクトを変更するだけ」という最もシンプルなメンタルモデルで状態管理を実現するライブラリです。Proxyによる自動的なレンダリング最適化と、React外からでも自由に状態を操作できる柔軟性が大きな魅力です。ボイラープレートを最小限に抑えたい方や、MobXのようなリアクティブな書き味をより軽量に実現したい方に最適な選択肢と言えるでしょう。