Zustand / Jotai 入門 — モダンな React 状態管理の選び方と使い方
Redux の複雑さに疲れたなら、Zustand や Jotai が救いになります。この記事では両ライブラリの使い方と使い分けの基準を、実際のコード例とともに解説します。
なぜ Zustand / Jotai が注目されているのか
React の状態管理には長らく Redux が使われてきましたが、設定量が多く小〜中規模のプロジェクトではオーバースペックになりがちです。Zustand と Jotai はどちらも軽量・シンプルで、Redux の代替として急速に普及しています。
| 項目 | Redux | Zustand | Jotai |
|---|---|---|---|
| バンドルサイズ | ~13kB | ~1kB | ~3kB |
| ボイラープレート | 多い | 少ない | 少ない |
| 学習コスト | 高い | 低い | 低い |
| 状態モデル | 単一ストア | 単一ストア(複数可) | アトム(細粒度) |
| SSR 対応 | 要設定 | 要設定 | ネイティブ対応 |
Zustand の使い方
インストール
npm install zustand
基本的なストアの作成
// store/useCounterStore.ts
import { create } from "zustand"
type CounterState = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
コンポーネントで使う
// components/Counter.tsx
"use client"
import { useCounterStore } from "@/store/useCounterStore"
export function Counter() {
const { count, increment, decrement, reset } = useCounterStore()
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
)
}
セレクターで必要な値だけ取得する(パフォーマンス最適化)
// count だけ購読 → count が変わったときだけ再レンダリング
const count = useCounterStore((state) => state.count)
// 複数の値を取得(shallow で比較)
import { useShallow } from "zustand/shallow"
const { count, increment } = useCounterStore(
useShallow((state) => ({ count: state.count, increment: state.increment }))
)
非同期アクション
export const useUserStore = create<UserState>((set) => ({
user: null,
loading: false,
fetchUser: async (id: number) => {
set({ loading: true })
const res = await fetch(`/api/users/${id}`)
const user = await res.json()
set({ user, loading: false })
},
}))
DevTools 連携・永続化
import { create } from "zustand"
import { devtools, persist } from "zustand/middleware"
// DevTools 連携
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, "increment"),
}),
{ name: "CounterStore" }
)
)
// localStorage に永続化
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: "light",
setTheme: (theme: string) => set({ theme }),
}),
{ name: "settings-storage" }
)
)
Jotai の使い方
Jotai は「アトム」という単位で状態を管理します。React の useState に近い感覚で使えます。
インストール
npm install jotai
基本的なアトムの作成と使い方
// atoms/counterAtom.ts
import { atom } from "jotai"
export const countAtom = atom(0)
// components/Counter.tsx
"use client"
import { useAtom } from "jotai"
import { countAtom } from "@/atoms/counterAtom"
export function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+</button>
<button onClick={() => setCount((c) => c - 1)}>-</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
)
}
useAtom は useState とほぼ同じ API です。既存の React コードから移行しやすいのが Jotai の強みです。
派生アトム(computed values)
import { atom } from "jotai"
export const countAtom = atom(0)
// 読み取り専用の派生アトム
export const doubledCountAtom = atom((get) => get(countAtom) * 2)
// 読み書き可能な派生アトム(上限付き)
export const countWithLimitAtom = atom(
(get) => get(countAtom),
(get, set, newValue: number) => {
set(countAtom, Math.min(newValue, 100))
}
)
非同期アトム(Suspense と統合)
import { atom } from "jotai"
const userIdAtom = atom(1)
// userIdAtom が変わると自動で再取得
export const userAtom = atom(async (get) => {
const id = get(userIdAtom)
const res = await fetch(`/api/users/${id}`)
return res.json()
})
import { Suspense } from "react"
import { useAtomValue } from "jotai"
import { userAtom } from "@/atoms/userAtom"
function UserProfile() {
const user = useAtomValue(userAtom) // Suspense と統合
return <p>{user.name}</p>
}
export function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserProfile />
</Suspense>
)
}
atomWithStorage(永続化)
import { atomWithStorage } from "jotai/utils"
export const themeAtom = atomWithStorage("theme", "light")
Next.js App Router との組み合わせ
Zustand を App Router で使う
SSR 環境でモジュールスコープの create() を使うと、異なるユーザーのリクエスト間でストアが共有されてしまう場合があります。createStore + React Context パターンで回避します。
// store/createCounterStore.ts
import { createStore } from "zustand"
export type CounterStore = {
count: number
increment: () => void
}
export const createCounterStore = (initCount = 0) =>
createStore<CounterStore>()((set) => ({
count: initCount,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
// app/providers.tsx
"use client"
import { createContext, useContext, useRef } from "react"
import { useStore } from "zustand"
import { createCounterStore, CounterStore } from "@/store/createCounterStore"
type CounterStoreApi = ReturnType<typeof createCounterStore>
const CounterStoreContext = createContext<CounterStoreApi | null>(null)
export function CounterStoreProvider({ children }: { children: React.ReactNode }) {
const storeRef = useRef<CounterStoreApi | null>(null)
if (!storeRef.current) {
storeRef.current = createCounterStore()
}
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
)
}
export function useCounterStore<T>(selector: (store: CounterStore) => T) {
const store = useContext(CounterStoreContext)
if (!store) throw new Error("CounterStoreProvider が必要です")
return useStore(store, selector)
}
Jotai を App Router で使う
Jotai は SSR 対応が設計に組み込まれており、Provider でスコープを分けるだけで安全に使えます。
// app/providers.tsx
"use client"
import { Provider } from "jotai"
export function Providers({ children }: { children: React.ReactNode }) {
return <Provider>{children}</Provider>
}
Zustand vs Jotai — どちらを選ぶか
グローバルな状態を 1 箇所で管理したい
→ Zustand(単一ストアが明確)
細かい状態を独立して管理したい
→ Jotai(アトム単位で分離)
非同期データ取得を状態と統合したい
→ Jotai(asyncAtom + Suspense が強力)
Redux からの移行
→ Zustand(ストア・セレクターの概念が近い)
useState からの移行
→ Jotai(API がほぼ同じ)
DevTools やミドルウェアを多用したい
→ Zustand(middleware エコシステムが豊富)
ハマりやすいポイント
Zustand: セレクターなしだと全再レンダリングが起きる
// NG: ストア全体を購読 → 何か変わるたびに再レンダリング
const store = useCounterStore()
// OK: 必要な値だけ選択
const count = useCounterStore((state) => state.count)
Jotai: Provider なしだとグローバルストアを使う
App Router での SSR では必ず Provider でラップして各リクエストを分離してください。
Jotai: 非同期アトムは Suspense が必須
// 非同期アトムを使うコンポーネントは必ず Suspense でラップ
<Suspense fallback={<Loading />}>
<UserProfile />
</Suspense>
まとめ
- Zustand はシンプルで直感的、Redux 経験者が移行しやすい。ストア単位で状態をまとめるプロジェクトに向いている
- Jotai はアトム単位の細粒度管理、Suspense との統合が強力。
useStateの延長として使える - Next.js App Router では SSR の状態汚染に注意。Zustand は
createStore+ Context、Jotai はProviderでスコープを分離する - 小〜中規模なら Zustand、非同期処理が複雑なら Jotai が向いている傾向がある