Zustand / Jotai 入門 — モダンな React 状態管理の選び方と使い方
Redux の複雑さに疲れたなら、Zustand や Jotai が救いになります。この記事では両ライブラリの使い方と使い分けの基準を、実際のコード例とともに解説します。
なぜ Zustand / Jotai が注目されているのか
React の状態管理には長らく Redux が使われてきました。しかし Redux は設定量が多く、小〜中規模のプロジェクトではオーバースペックになりがちです。
Zustand(ドイツ語で「状態」)と Jotai(日本語の「状態」)はどちらも軽量・シンプルな状態管理ライブラリで、Redux の代替として急速に普及しています。
| 項目 | Redux | Zustand | Jotai |
|---|---|---|---|
| バンドルサイズ | ~13kB | ~1kB | ~3kB |
| ボイラープレート | 多い | 少ない | 少ない |
| 学習コスト | 高い | 低い | 低い |
| 状態モデル | 単一ストア | 単一ストア(複数可) | アトム(細粒度) |
| DevTools | Redux DevTools | あり | あり |
| 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 }))
)
非同期アクション
// store/useUserStore.ts
import { create } from "zustand"
type User = { id: number; name: string }
type UserState = {
user: User | null
loading: boolean
fetchUser: (id: number) => Promise<void>
}
export const useUserStore = create<UserState>((set) => ({
user: null,
loading: false,
fetchUser: async (id) => {
set({ loading: true })
const res = await fetch(`/api/users/${id}`)
const user = await res.json()
set({ user, loading: false })
},
}))
Zustand の DevTools 連携
import { create } from "zustand"
import { devtools } from "zustand/middleware"
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, "increment"),
}),
{ name: "CounterStore" }
)
)
永続化(localStorage に保存)
import { create } from "zustand"
import { persist } from "zustand/middleware"
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{ name: "settings-storage" } // localStorage のキー名
)
)
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)
// count を2倍にした派生アトム(読み取り専用)
export const doubledCountAtom = atom((get) => get(countAtom) * 2)
// 読み書き可能な派生アトム
export const countWithLimitAtom = atom(
(get) => get(countAtom),
(get, set, newValue: number) => {
const limited = Math.min(newValue, 100) // 最大100
set(countAtom, limited)
}
)
非同期アトム
import { atom } from "jotai"
const userIdAtom = atom(1)
// fetchAtom: 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 との組み合わせ
App Router 環境では「Server Component 内でストアを使えない」という制約があります。
Zustand を App Router で使う
// app/providers.tsx
"use client"
import { createStore, Provider } from "zustand"
// サーバーとクライアントで同じストアを共有しないよう
// リクエストごとに新しいストアを作成する
export function Providers({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
注意: Zustand のストアはモジュールスコープで作られるため、SSR 環境でリクエスト間の状態汚染が起きることがあります。
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>
}
// app/layout.tsx
import { Providers } from "./providers"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
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)
Zustand: SSR でストアが共有される問題
Next.js の SSR 環境でモジュールスコープの create() を使うと、異なるユーザーのリクエスト間でストアが共有されてしまう場合があります。上記の createStore + Context パターンで回避してください。
Jotai: Provider なしだとグローバルストアを使う
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 が向いている傾向がある