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

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

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

React の状態管理には長らく Redux が使われてきましたが、設定量が多く小〜中規模のプロジェクトではオーバースペックになりがちです。ZustandJotai はどちらも軽量・シンプルで、Redux の代替として急速に普及しています。

項目ReduxZustandJotai
バンドルサイズ~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>
  )
}

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