Skip to content
Published on

状態管理完全比較2025:React Context vs Zustand vs Jotai vs Redux Toolkit vs Recoil

Authors

1. 2025年(ねん)の状態管理(じょうたいかんり)ランドスケープ

npmダウンロードトレンド

2025年(ねん)のReactエコシステムにおける状態管理(じょうたいかんり)ライブラリの競争(きょうそう)はかつてないほど激(はげ)しくなっています。週間(しゅうかん)ダウンロード数(すう)の内訳(うちわけ)は以下(いか)の通(とお)りです。

ライブラリ週間(しゅうかん)ダウンロード(2025)バンドルサイズ初回(しょかい)リリース
Redux (+ Toolkit)~9M11KB (RTK)2015
Zustand~5.5M1.1KB2019
TanStack Query~4.5M13KB2020
Jotai~2.2M3.4KB2020
Recoil~500K21KB2020

パラダイムの転換(てんかん)

2025年(ねん)の最(もっと)も重要(じゅうよう)な変化(へんか)は**サーバー状態(じょうたい)とクライアント状態(じょうたい)の分離(ぶんり)**です。以前(いぜん)はReduxで全(すべ)てを管理(かんり)していましたが、今(いま)は明確(めいかく)な役割分担(やくわりぶんたん)が行(おこな)われています。

  • サーバー状態(じょうたい): TanStack Query / SWR(APIデータ、キャッシング、同期(どうき))
  • クライアント状態(じょうたい): Zustand / Jotai / Redux Toolkit(UI状態(じょうたい)、フォーム状態(じょうたい)、テーマ)
  • URL状態(じょうたい): nuqs / next-usequerystate(検索(けんさく)フィルタ、ページネーション)
  • フォーム状態(じょうたい): React Hook Form / Formik(入力値(にゅうりょくち)、バリデーション)
┌─────────────────────────────────────────────────┐
Application State├──────────────┬──────────────┬───────────────────┤
Server StateClient StateURL / Form StateTanStack QZustand    │  nuqs + RHFSWRJotai      │                   │
│              │   Redux TK   │                   │
└──────────────┴──────────────┴───────────────────┘

2. サーバー状態革命(かくめい):TanStack QueryとSWR

なぜサーバー状態(じょうたい)を分離(ぶんり)するのか

従来(じゅうらい)のReduxパターンでは、API呼(よ)び出(だ)しの結果(けっか)をグローバルストアに保存(ほぞん)していました。しかし、このアプローチには深刻(しんこく)な問題(もんだい)があります。

  • キャッシュ無効化(むこうか): データを再取得(さいしゅとく)するタイミングを手動(しゅどう)で管理(かんり)する必要(ひつよう)がある
  • ローディング/エラー状態(じょうたい): 毎回(まいかい)isLoadingとerror状態(じょうたい)を手動(しゅどう)で管理(かんり)
  • 重複(ちょうふく)リクエスト: 複数(ふくすう)のコンポーネントが同(おな)じデータを要求(ようきゅう)すると重複(ちょうふく)API呼(よ)び出(だ)しが発生(はっせい)
  • 楽観的更新(らっかんてきこうしん): 実装(じっそう)が非常(ひじょう)に複雑(ふくざつ)

TanStack Query (React Query v5)

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// サーバー状態の取得 - キャッシングと再検証を自動処理
function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => res.json()),
    staleTime: 5 * 60 * 1000, // 5分間freshを維持
    gcTime: 30 * 60 * 1000,   // 30分後にガベージコレクション
  })
}

// サーバー状態の変更 - 楽観的更新を含む
function useCreateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (newUser: User) =>
      fetch('/api/users', {
        method: 'POST',
        body: JSON.stringify(newUser),
      }).then(res => res.json()),

    // 楽観的更新
    onMutate: async (newUser) => {
      await queryClient.cancelQueries({ queryKey: ['users'] })
      const previous = queryClient.getQueryData(['users'])
      queryClient.setQueryData(['users'], (old: User[]) => [...old, newUser])
      return { previous }
    },
    onError: (err, newUser, context) => {
      queryClient.setQueryData(['users'], context?.previous)
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

Stale-While-Revalidateパターン

ユーザーリクエスト → キャッシュ確認 → キャッシュデータを即座に返却 (stale)
                        バックグラウンドで新データを取得 (revalidate)
                        新データでUIを更新

このパターンのおかげで、ユーザーは常(つね)に即座(そくざ)の応答(おうとう)を受(う)けながら、最新(さいしん)データを自動的(じどうてき)に受(う)け取(と)ります。

SWR vs TanStack Query比較(ひかく)

機能(きのう)TanStack Query v5SWR v2
バンドルサイズ13KB4.2KB
MutationサポートuseMutation内蔵(ないぞう)手動(しゅどう)実装(じっそう)
楽観的更新(らっかんてきこうしん)ファーストクラスサポート手動(しゅどう)実装(じっそう)
Devtools専用(せんよう)DevToolsなし
無限(むげん)スクロールuseInfiniteQueryuseSWRInfinite
SSRサポートHydration APINext.js統合(とうごう)

3. React Context:いつ十分(じゅうぶん)か

Contextが適切(てきせつ)な場合(ばあい)

React Contextは追加(ついか)ライブラリなしで使用(しよう)できる組(く)み込(こ)みの状態共有(じょうたいきょうゆう)メカニズムです。

// テーマ、認証、ロケールなど変更が稀な状態に適合
const ThemeContext = createContext<ThemeContextType | null>(null)

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')

  const value = useMemo(() => ({ theme, setTheme }), [theme])

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  )
}

function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) throw new Error('useTheme must be used within ThemeProvider')
  return context
}

Contextのパフォーマンスの罠(わな)

Contextの最大(さいだい)の問題(もんだい)は、valueが変更(へんこう)されると全(すべ)ての消費者(しょうひしゃ)が再(さい)レンダリングされることです。

// 悪いパターン:すべての状態を1つのContextに
const AppContext = createContext({
  user: null,
  theme: 'light',
  notifications: [],
  sidebar: false,
})

// 問題:sidebarだけ変更しても、user、theme、notificationsを
// 使用する全てのコンポーネントが再レンダリングされます!

Compositionパターンで解決(かいけつ)

// 良いパターン:状態ごとにContextを分離
function App() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <NotificationProvider>
          <SidebarProvider>
            <Layout />
          </SidebarProvider>
        </NotificationProvider>
      </AuthProvider>
    </ThemeProvider>
  )
}

// 各Contextは自分の状態のみを管理するため
// 不要な再レンダリングが発生しません

Contextが不十分(ふじゅうぶん)な場合(ばあい)

以下(いか)の状況(じょうきょう)では専用(せんよう)の状態管理(じょうたいかんり)ライブラリが必要(ひつよう)です。

  • 状態(じょうたい)が頻繁(ひんぱん)に変更(へんこう)される場合(ばあい)(カウンタ、タイマー、リアルタイムデータ)
  • 特定(とくてい)の状態(じょうたい)スライスに対(たい)する細粒度(さいりゅうど)のサブスクリプションが必要(ひつよう)な場合(ばあい)
  • ミドルウェアが必要(ひつよう)な場合(ばあい)(ロギング、永続化(えいぞくか)、開発者(かいはつしゃ)ツール)
  • 状態変更(じょうたいへんこう)ロジックが複雑(ふくざつ)な場合(ばあい)

4. Redux Toolkit:まだ必要(ひつよう)か?

RTKの現在(げんざい)の位置(いち)

Redux Toolkit(RTK)はReduxのボイラープレート問題(もんだい)を解決(かいけつ)し、公式(こうしき)推奨(すいしょう)となりました。2025年(ねん)でも大規模(だいきぼ)エンタープライズで依然(いぜん)として強力(きょうりょく)な選択肢(せんたくし)です。

createSlice:ボイラープレートの排除(はいじょ)

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface TodoState {
  items: Todo[]
  filter: 'all' | 'active' | 'completed'
}

const todoSlice = createSlice({
  name: 'todos',
  initialState: { items: [], filter: 'all' } as TodoState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      // Immerが内蔵されているため不変性を気にする必要がありません
      state.items.push({
        id: crypto.randomUUID(),
        text: action.payload,
        completed: false,
      })
    },
    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.items.find(t => t.id === action.payload)
      if (todo) todo.completed = !todo.completed
    },
    setFilter: (state, action: PayloadAction<TodoState['filter']>) => {
      state.filter = action.payload
    },
  },
})

export const { addTodo, toggleTodo, setFilter } = todoSlice.actions
export default todoSlice.reducer

RTK Query:組(く)み込(こ)みサーバー状態管理(じょうたいかんり)

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'],
    }),
    createUser: builder.mutation<User, Partial<User>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['User'],
    }),
  }),
})

export const { useGetUsersQuery, useCreateUserMutation } = api

Redux Toolkitが依然(いぜん)として必要(ひつよう)な場合(ばあい)

  • 大規模(だいきぼ)チーム: 厳格(げんかく)なパターンとコンベンションが必要(ひつよう)なチーム
  • 複雑(ふくざつ)なビジネスロジック: 複数(ふくすう)の状態間(じょうたいかん)の複雑(ふくざつ)な相互作用(そうごさよう)
  • タイムトラベルデバッグ: Redux DevToolsの強力(きょうりょく)なデバッグ
  • ミドルウェアエコシステム: saga、thunkなど豊富(ほうふ)なミドルウェアサポート
  • レガシー互換性(ごかんせい): 既存(きそん)のReduxコードベースからの段階的(だんかいてき)マイグレーション

5. Zustand:ミニマリズムの勝利(しょうり)

なぜZustandか

Zustandは1.1KBという驚異的(きょういてき)なバンドルサイズの最(もっと)も軽量(けいりょう)な状態管理(じょうたいかんり)ライブラリです。Providerが不要(ふよう)で、ボイラープレートが最小限(さいしょうげん)で、TypeScriptと完全(かんぜん)に互換性(ごかんせい)があります。

基本(きほん)ストアパターン

import { create } from 'zustand'

interface BearStore {
  bears: number
  increase: () => void
  decrease: () => void
  reset: () => void
}

const useBearStore = create<BearStore>((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
  decrease: () => set((state) => ({ bears: state.bears - 1 })),
  reset: () => set({ bears: 0 }),
}))

// 使用:Providerなしでどこからでも呼び出し
function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <span>{bears} bears</span>
}

function BearControls() {
  const increase = useBearStore((state) => state.increase)
  return <button onClick={increase}>Add bear</button>
}

ミドルウェア:persist、devtools、immer

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface AppStore {
  user: User | null
  preferences: Preferences
  setUser: (user: User | null) => void
  updatePreference: (key: string, value: unknown) => void
}

const useAppStore = create<AppStore>()(
  devtools(
    persist(
      immer((set) => ({
        user: null,
        preferences: { theme: 'light', language: 'ja' },

        setUser: (user) => set((state) => {
          state.user = user
        }),

        updatePreference: (key, value) => set((state) => {
          state.preferences[key] = value
        }),
      })),
      {
        name: 'app-storage', // localStorageキー
        partialize: (state) => ({
          preferences: state.preferences,
        }),
      }
    ),
    { name: 'AppStore' } // DevToolsラベル
  )
)

スライスパターン:大規模(だいきぼ)ストアの分割(ぶんかつ)

// userSlice.ts
const createUserSlice = (set: SetState, get: GetState) => ({
  user: null,
  login: async (credentials: Credentials) => {
    const user = await authApi.login(credentials)
    set({ user })
  },
  logout: () => set({ user: null }),
})

// cartSlice.ts
const createCartSlice = (set: SetState, get: GetState) => ({
  items: [],
  addItem: (item: CartItem) =>
    set((state) => ({ items: [...state.items, item] })),
  totalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price, 0),
})

// 統合
const useStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}))

Zustandの強(つよ)み

  • 1.1KB バンドルサイズ (gzipped)
  • Provider不要(ふよう): コンポーネントツリーのどこからでも使用可能(かのう)
  • 選択的(せんたくてき)サブスクリプション: (state) => state.bearsで必要(ひつよう)な状態(じょうたい)のみサブスクライブ
  • React外(そと)でも動作(どうさ): Vanilla JSでも使用可能(かのう)
  • SSR互換(ごかん): Next.jsと完全(かんぜん)互換(ごかん)

6. Jotai:原子的状態(げんしてきじょうたい)の力(ちから)

Atomベースの設計(せっけい)

Jotaiは「原子(アトム)」単位(たんい)で状態(じょうたい)を管理(かんり)します。各(かく)atomは独立(どくりつ)しており、サブスクライブしているコンポーネントのみが再(さい)レンダリングされます。

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'

// 基本atom
const countAtom = atom(0)
const nameAtom = atom('Guest')

// 派生atom (derived)
const doubleCountAtom = atom((get) => get(countAtom) * 2)

// 読み書きatom
const countWithLogAtom = atom(
  (get) => get(countAtom),
  (get, set, newValue: number) => {
    console.log(`Count changed: ${get(countAtom)} -> ${newValue}`)
    set(countAtom, newValue)
  }
)

// 使用
function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const doubleCount = useAtomValue(doubleCountAtom)

  return (
    <div>
      <p>Count: {count}, Double: {doubleCount}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

非同期(ひどうき)AtomとSuspense統合(とうごう)

// 非同期atom - React Suspenseと自動統合
const userAtom = atom(async () => {
  const response = await fetch('/api/user')
  return response.json()
})

// 依存関係のある非同期atom
const userPostsAtom = atom(async (get) => {
  const user = await get(userAtom)
  const response = await fetch(`/api/users/${user.id}/posts`)
  return response.json()
})

// Suspenseでローディング処理
function UserPosts() {
  const posts = useAtomValue(userPostsAtom)
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserPosts />
    </Suspense>
  )
}

atomFamily:動的(どうてき)Atom生成(せいせい)

import { atomFamily } from 'jotai/utils'

// IDごとに独立したatomを生成
const todoAtomFamily = atomFamily((id: string) =>
  atom({
    id,
    text: '',
    completed: false,
  })
)

// 各Todoは独立したatomで管理されるため
// 1つのTodoの変更が他のTodoの再レンダリングを引き起こしません
function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id))
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => setTodo(prev => ({ ...prev, completed: !prev.completed }))}
      />
      {todo.text}
    </label>
  )
}

Jotaiの強(つよ)み

  • 3.4KB バンドルサイズ (gzipped)
  • 原子的(げんしてき)再(さい)レンダリング: 変更(へんこう)されたatomをサブスクライブするコンポーネントのみ再(さい)レンダリング
  • React Suspense統合(とうごう): 非同期状態(ひどうきじょうたい)を宣言的(せんげんてき)に処理(しょり)
  • ボトムアップアプローチ: 小(ちい)さな単位(たんい)から状態(じょうたい)を構成(こうせい)
  • DevToolsサポート: Jotai DevTools、React DevTools統合(とうごう)

7. Recoil:Metaの選択(せんたく)、しかし衰退(すいたい)

Recoilの現在(げんざい)の状態(じょうたい)

RecoilはMeta(Facebook)が作成(さくせい)した原子的状態管理(げんしてきじょうたいかんり)ライブラリです。しかし2025年(ねん)現在(げんざい)、メンテナンスが停滞(ていたい)し、リリース間隔(かんかく)が長(なが)くなっています。

基本的(きほんてき)な使用法(しようほう)

import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'

const todoListAtom = atom({
  key: 'todoList',
  default: [] as Todo[],
})

const filteredTodoSelector = selector({
  key: 'filteredTodos',
  get: ({ get }) => {
    const list = get(todoListAtom)
    const filter = get(todoFilterAtom)
    switch (filter) {
      case 'completed': return list.filter(t => t.completed)
      case 'active': return list.filter(t => !t.completed)
      default: return list
    }
  },
})

// atomFamily - 動的atom
const userAtomFamily = atomFamily({
  key: 'user',
  default: (userId: string) => async () => {
    const res = await fetch(`/api/users/${userId}`)
    return res.json()
  },
})

Recoil vs Jotai

項目(こうもく)RecoilJotai
バンドルサイズ21KB3.4KB
Key必要(ひつよう)必須(ひっす)(文字列(もじれつ)key)不要(ふよう)
ProviderRecoilRoot必須(ひっす)任意(にんい)
メンテナンス停滞(ていたい)活発(かっぱつ)
SSR制限的(せいげんてき)完全(かんぜん)サポート
コミュニティ減少傾向(げんしょうけいこう)増加傾向(ぞうかけいこう)

JotaiがRecoilの全(すべ)ての機能(きのう)をより小(ちい)さなバンドルで提供(ていきょう)し、活発(かっぱつ)にメンテナンスされているため、新規(しんき)プロジェクトではJotaiを推奨(すいしょう)します。


8. Signals:新(あたら)しいパラダイム

Signalsとは

Signalsは細粒度(さいりゅうど)の反応性(はんのうせい)(fine-grained reactivity)を提供(ていきょう)する状態(じょうたい)プリミティブです。Reactの再(さい)レンダリングモデルとは異(こと)なり、Signalが変更(へんこう)されると、そのSignalを使用(しよう)するDOM部分(ぶぶん)のみが直接(ちょくせつ)更新(こうしん)されます。

Preact Signals

import { signal, computed, effect } from '@preact/signals-react'

// Signal作成
const count = signal(0)
const doubled = computed(() => count.value * 2)

// コンポーネントで使用 - 再レンダリングなしでDOM直接更新
function Counter() {
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={() => count.value++}>+1</button>
    </div>
  )
}

// 副作用
effect(() => {
  console.log(`Count is now: ${count.value}`)
})

SolidJS Signals

import { createSignal, createMemo, createEffect } from 'solid-js'

function Counter() {
  const [count, setCount] = createSignal(0)
  const doubled = createMemo(() => count() * 2)

  createEffect(() => {
    console.log(`Count: ${count()}`)
  })

  return (
    <div>
      <p>Count: {count()}, Doubled: {doubled()}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

Angular Signals (v17+)

import { signal, computed, effect } from '@angular/core'

@Component({
  template: `
    <p>Count: {{ count() }}, Doubled: {{ doubled() }}</p>
    <button (click)="increment()">+1</button>
  `
})
class CounterComponent {
  count = signal(0)
  doubled = computed(() => this.count() * 2)

  constructor() {
    effect(() => {
      console.log(`Count: ${this.count()}`)
    })
  }

  increment() {
    this.count.update(c => c + 1)
  }
}

ReactとSignalsの関係(かんけい)

Reactチームは公式(こうしき)にはSignalsを採用(さいよう)していません。代(か)わりに、React 19のコンパイラ(React Forget)が同様(どうよう)の最適化(さいてきか)を自動的(じどうてき)に実行(じっこう)します。つまり、Reactは**コンパイラベースの最適化(さいてきか)**を選択(せんたく)しました。

アプローチフレームワーク原理(げんり)
SignalsPreact, Solid, Angularランタイムで細粒度(さいりゅうど)の反応性追跡(ついせき)
CompilerReact 19ビルド時(じ)に自動(じどう)メモ化(か)
ProxyMobX, ValtioProxyオブジェクトで変更検知(へんこうけんち)

9. 総合比較表(そうごうひかくひょう)

機能比較(きのうひかく)

項目(こうもく)ContextRedux TKZustandJotaiRecoil
バンドルサイズ0KB(内蔵(ないぞう))11KB1.1KB3.4KB21KB
ボイラープレート中(ちゅう)低(てい)(RTK)非常(ひじょう)に低(てい)非常(ひじょう)に低(てい)中(ちゅう)
学習曲線(がくしゅうきょくせん)低(てい)中(ちゅう)低(てい)低(てい)中(ちゅう)
TypeScript良好(りょうこう)非常(ひじょう)に良好(りょうこう)非常(ひじょう)に良好(りょうこう)非常(ひじょう)に良好(りょうこう)良好(りょうこう)
SSRサポート良好(りょうこう)良好(りょうこう)非常(ひじょう)に良好(りょうこう)非常(ひじょう)に良好(りょうこう)制限的(せいげんてき)
DevToolsReact DTRedux DTRedux DTJotai DT制限的(せいげんてき)
ミドルウェアなし豊富(ほうふ)persist/devtoolsユーティリティなし
選択的(せんたくてき)サブスクリプション不可(ふか)useSelectorselectorFnatom単位(たんい)selector
Provider必要(ひつよう)必須(ひっす)必須(ひっす)不要(ふよう)任意(にんい)必須(ひっす)
非同期(ひどうき)サポートuseEffectcreateAsyncThunk手動(しゅどう)SuspenseSuspense

パフォーマンスベンチマーク(1,000項目(こうもく)リスト)

シナリオContextRedux TKZustandJotai
単一(たんいつ)項目(こうもく)更新(こうしん)12.3ms2.1ms1.8ms0.9ms
全(ぜん)リスト更新(こうしん)15.2ms8.7ms7.9ms8.2ms
メモリ使用量(しようりょう)基準(きじゅん)+2.1MB+0.3MB+0.5MB
初期(しょき)レンダリング45ms52ms44ms46ms

10. プロジェクト別(べつ)選択(せんたく)ガイド

判断(はんだん)フレームワーク

プロジェクト規模は?
├── 小規模(しょうきぼ)(1-3人、シンプルUI)
│   ├── 状態が頻繁に変わりますか?
│   │   ├── YesZustand
│   │   └── NoReact Context
│   └── サーバーデータが中心ですか?
│       └── YesTanStack Queryだけで十分
├── 中規模(ちゅうきぼ)(4-10人、複雑なUI)
│   ├── 多くの独立した状態がありますか?
│   │   ├── YesJotai
│   │   └── NoZustand
│   └── サーバー状態が複雑ですか?
│       └── YesTanStack Query + Zustand/Jotai
└── 大規模(だいきぼ)(10人以上、エンタープライズ)
    ├── 既存のReduxコードがありますか?
    │   ├── YesRedux Toolkitへマイグレーション
    │   └── NoZustand + TanStack Query
    └── 厳格なアーキテクチャが必要ですか?
        └── YesRedux Toolkit

シナリオ別(べつ)推奨(すいしょう)の組(く)み合(あ)わせ

シナリオ推奨(すいしょう)の組(く)み合(あ)わせ
シンプルなランディングページReact Context
SaaSダッシュボードZustand + TanStack Query
EコマースJotai + TanStack Query
ソーシャルメディアアプリZustand + TanStack Query
大規模(だいきぼ)エンタープライズRedux Toolkit + RTK Query
リアルタイムコラボレーションツールZustand + WebSocketミドルウェア
エディタ/IDEツールJotai(細粒度(さいりゅうど)の状態制御(じょうたいせいぎょ))

11. マイグレーションガイド

ReduxからZustandへ

// Before: Redux Toolkit
// store.ts
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1 },
    decrement: (state) => { state.value -= 1 },
  },
})

// コンポーネント
function Counter() {
  const count = useSelector((state: RootState) => state.counter.value)
  const dispatch = useDispatch()
  return <button onClick={() => dispatch(increment())}>Count: {count}</button>
}

// After: Zustand
const useCounterStore = create<CounterStore>((set) => ({
  value: 0,
  increment: () => set((s) => ({ value: s.value + 1 })),
  decrement: () => set((s) => ({ value: s.value - 1 })),
}))

function Counter() {
  const count = useCounterStore((s) => s.value)
  const increment = useCounterStore((s) => s.increment)
  return <button onClick={increment}>Count: {count}</button>
}

ContextからJotaiへ

// Before: React Context
const CountContext = createContext({ count: 0, setCount: () => {} })

function CountProvider({ children }) {
  const [count, setCount] = useState(0)
  return (
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  )
}

function Counter() {
  const { count, setCount } = useContext(CountContext)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}

// After: Jotai
const countAtom = atom(0)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
// Provider削除!コード50%削減!

マイグレーションチェックリスト

  • 既存(きそん)の状態(じょうたい)をサーバー/クライアントに分類(ぶんるい)
  • サーバー状態(じょうたい)をTanStack Queryに移行(いこう)
  • クライアント状態(じょうたい)を選択(せんたく)したライブラリに移行(いこう)
  • テストコードを更新(こうしん)
  • パフォーマンスベンチマークを比較(ひかく)
  • 段階的(だんかいてき)ロールアウト(機能(きのう)ごとのマイグレーション)

12. 面接質問(めんせつしつもん)とクイズ

面接質問(めんせつしつもん)10選(せん)

Q1. サーバー状態(じょうたい)とクライアント状態(じょうたい)の違(ちが)いを説明(せつめい)し、それぞれに適(てき)したツールを推奨(すいしょう)してください。

サーバー状態(じょうたい)はAPIから取得(しゅとく)する非同期(ひどうき)データで、キャッシング、無効化(むこうか)、同期(どうき)が核心(かくしん)です。TanStack QueryやSWRが適切(てきせつ)です。クライアント状態(じょうたい)はUIトグルやフォーム入力(にゅうりょく)など純粋(じゅんすい)なフロントエンド状態(じょうたい)で、Zustand、Jotai、Contextが適切(てきせつ)です。

Q2. React Contextのパフォーマンス問題(もんだい)は何(なん)ですか、どのように解決(かいけつ)しますか?

Contextのvalueが変更(へんこう)されると、そのContextをサブスクライブする全(すべ)てのコンポーネントが再(さい)レンダリングされます。解決策(かいけつさく)はContextの分割(ぶんかつ)、useMemoでのvalue メモ化(か)、またはZustand/Jotaiへの移行(いこう)です。

Q3. ZustandとJotaiの核心的(かくしんてき)な違(ちが)いは何(なん)ですか?

Zustandはストアベース(トップダウン)アプローチで、関連(かんれん)する状態(じょうたい)を1つのストアにまとめます。Jotaiはatomベース(ボトムアップ)アプローチで、状態(じょうたい)を最小単位(さいしょうたんい)に分割(ぶんかつ)します。単一(たんいつ)ストアが自然(しぜん)な場合(ばあい)はZustand、多(おお)くの独立(どくりつ)した状態(じょうたい)がある場合(ばあい)はJotaiが適切(てきせつ)です。

Q4. Redux Toolkitが2025年(ねん)でも有効(ゆうこう)な理由(りゆう)を説明(せつめい)してください。

厳格(げんかく)なパターンの強制(きょうせい)、タイムトラベルデバッグ、豊富(ほうふ)なミドルウェアエコシステム、内蔵(ないぞう)RTK Query、大規模(だいきぼ)チームでのコード一貫性(いっかんせい)の維持(いじ)が利点(りてん)です。

Q5. Stale-While-Revalidateパターンを説明(せつめい)してください。

キャッシュされた(stale)データを即座(そくざ)に返(かえ)して高速(こうそく)なUXを提供(ていきょう)しつつ、バックグラウンドで新(あたら)しいデータを取得(しゅとく)し(revalidate)、UIを更新(こうしん)します。TanStack QueryとSWRの核心戦略(かくしんせんりゃく)です。

Q6. SignalsはReactの再(さい)レンダリングモデルとどう違(ちが)いますか?

Reactは状態変更時(じょうたいへんこうじ)にコンポーネント関数全体(かんすうぜんたい)を再実行(さいじっこう)し、Virtual DOMを比較(ひかく)します。Signalsは変更(へんこう)された値(あたい)を使用(しよう)するDOM部分(ぶぶん)のみを直接更新(ちょくせつこうしん)し、不要(ふよう)なコンポーネント再実行(さいじっこう)を回避(かいひ)します。

Q7. ZustandのpersistミドルウェアがSSRで引(ひ)き起(お)こす問題(もんだい)とその解決法(かいけつほう)は?

サーバーにはlocalStorageが存在(そんざい)しないため、hydration mismatchが発生(はっせい)します。skipHydrationオプションやuseEffect内(ない)での手動(しゅどう)hydrationで解決(かいけつ)します。

Q8. JotaiのderiveされたatomとRecoilのselectorの違(ちが)いを説明(せつめい)してください。

機能的(きのうてき)には類似(るいじ)していますが、Jotaiは文字列(もじれつ)keyが不要(ふよう)で、バンドルサイズが6倍(ばい)小(ちい)さいです。Jotai atomは参照同一性(さんしょうどういつせい)で識別(しきべつ)されるため、keyの衝突(しょうとつ)リスクがありません。

Q9. TanStack QueryのqueryKey設計戦略(せっけいせんりゃく)は?

階層的(かいそうてき)なキー構造(こうぞう)(例:['users', userId, 'posts'])を使用(しよう)して細粒度(さいりゅうど)のキャッシュ無効化(むこうか)を可能(かのう)にします。親(おや)キーを無効化(むこうか)すると子(こ)キーも自動的(じどうてき)に無効化(むこうか)されます。

Q10. 新(あたら)しいプロジェクトで状態管理(じょうたいかんり)ツールを選択(せんたく)する基準(きじゅん)は?

プロジェクト規模(きぼ)、チーム経験(けいけん)、SSR要件(ようけん)、バンドルサイズ制約(せいやく)、状態(じょうたい)の複雑度(ふくざつど)を考慮(こうりょ)します。ほとんどのプロジェクトはTanStack Query + Zustandの組(く)み合(あ)わせから始(はじ)めるのが良(よ)いでしょう。

クイズ5問(もん)

Q1. ZustandでProviderが不要(ふよう)な理由(りゆう)は?

Zustandはモジュールスコープでストアを作成(さくせい)し、グローバルシングルトンとして管理(かんり)します。Reactコンポーネントツリーの外部(がいぶ)で状態(じょうたい)を管理(かんり)するため、Providerでラップする必要(ひつよう)がありません。これにより設定(せってい)が簡素化(かんそか)され、Providerのネスト問題(もんだい)が排除(はいじょ)されます。

Q2. TanStack QueryのstaleTimeとgcTimeの違(ちが)いは?

staleTimeはデータがfresh状態(じょうたい)を維持(いじ)する時間(じかん)です。freshデータは再取得(さいしゅとく)されません。gcTime(ガベージコレクション時間(じかん))は非(ひ)アクティブなクエリデータがメモリから削除(さくじょ)されるまでの時間(じかん)です。デフォルト値(ち)はstaleTimeが0、gcTimeが5分(ふん)です。

Q3. JotaiのatomがRecoilのatomより優(すぐ)れている点(てん)を2つ挙(あ)げてください。

第一(だいいち)に、文字列(もじれつ)keyが不要(ふよう)です。Jotai atomはJavaScriptオブジェクト参照(さんしょう)で識別(しきべつ)されるため、keyの衝突(しょうとつ)リスクがありません。第二(だいに)に、バンドルサイズが3.4KBでRecoilの21KBの約(やく)6分(ぶん)の1です。

Q4. useMemoだけでReact Contextの再(さい)レンダリング問題(もんだい)を完全(かんぜん)に解決(かいけつ)できますか?

いいえ。useMemoはvalueオブジェクトの不要(ふよう)な再生成(さいせいせい)を防(ふせ)ぎますが、オブジェクト内(ない)の実際(じっさい)の値(あたい)が変更(へんこう)されると、依然(いぜん)として全(すべ)ての消費者(しょうひしゃ)が再(さい)レンダリングされます。Contextを分割(ぶんかつ)するか、選択的(せんたくてき)サブスクリプションをサポートするライブラリ(Zustand、Jotai)を使用(しよう)する必要(ひつよう)があります。

Q5. Redux ToolkitからZustandへマイグレーションする際(さい)の最(もっと)も重要(じゅうよう)な注意点(ちゅういてん)は?

ZustandにはReduxのミドルウェアチェーンと同(おな)じ概念(がいねん)がないため、sagaや複雑(ふくざつ)な非同期(ひどうき)フローを再設計(さいせっけい)する必要(ひつよう)があります。また、Redux DevToolsのタイムトラベルデバッグ機能(きのう)はZustandでは制限的(せいげんてき)にしか使用(しよう)できません。段階的(だんかいてき)マイグレーションのために、両方(りょうほう)のライブラリを一時的(いちじてき)に並行運用(へいこううんよう)する戦略(せんりゃく)が必要(ひつよう)です。


参考資料(さんこうしりょう)

  1. Zustand GitHub Repository
  2. Jotai Documentation
  3. Redux Toolkit Official Docs
  4. TanStack Query Documentation
  5. Recoil Documentation
  6. Preact Signals
  7. SolidJS Reactivity
  8. Angular Signals RFC
  9. SWR Documentation
  10. React Documentation - Context
  11. npm trends: State Management Libraries
  12. Theo Browne - State Management in 2025
  13. Jack Herrington - Zustand vs Jotai
  14. TkDodo - Practical React Query