Zustand / Jotai 入門 — モダンな React 状態管理の選び方と使い方

Redux の複雑さに疲れたなら、Zustand や Jotai が救いになります。この記事では両ライブラリの使い方と使い分けの基準を、実際のコード例とともに解説します。

なぜ Zustand / Jotai が注目されているのか

React の状態管理には長らく Redux が使われてきました。しかし Redux は設定量が多く、小〜中規模のプロジェクトではオーバースペックになりがちです。

Zustand(ドイツ語で「状態」)と Jotai(日本語の「状態」)はどちらも軽量・シンプルな状態管理ライブラリで、Redux の代替として急速に普及しています。

項目ReduxZustandJotai
バンドルサイズ~13kB~1kB~3kB
ボイラープレート多い少ない少ない
学習コスト高い低い低い
状態モデル単一ストア単一ストア(複数可)アトム(細粒度)
DevToolsRedux 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>
  )
}

useAtomuseState とほぼ同じ 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 が向いている傾向がある