mcpField
R
react-state-management

react-state-management

github.com/sickn33開発49.0k461 回インストール1 日前に更新
リポジトリ

react-state-management について

React 開発者向けの状態管理ガイドで、ローカル、グローバル、サーバー同期状態を扱います。Redux Toolkit、Zustand、Jotai、React Query などを比較し、実装や移行、問題の調査に役立つパターンを示します。

主な機能

  1. 015 種類の状態と用途の整理
  2. 02代表的な状態管理手法の比較
  3. 03TypeScript の実装パターン
  4. 04クライアント状態とサーバー状態の併用
  5. 05移行、安全性、検証の指針

活用シーン

  1. 01React アプリのグローバル状態を選定
  2. 02キャッシュとサーバーデータの管理
  3. 03楽観的更新と並行書き込みへの対応
  4. 04旧式 Redux から現代的な構成へ移行

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/sickn33/agentic-awesome-skills --skill react-state-management

インストール後、Claude Code で/react-state-managementを入力して読み込まれたか確認します。

詳細

React の状態管理

ローカルのコンポーネント状態からグローバルストアやサーバー状態の同期まで、最新の React 状態管理パターンを網羅したガイドです。

このスキルを使用しない場合

  • タスクが React の状態管理に関係しない場合
  • この範囲外の別の分野やツールが必要な場合

指示

  • 目標、制約、必要な入力を明確にします。
  • 関連するベストプラクティスを適用し、結果を検証します。
  • 実行可能な手順と検証方法を提示します。

このスキルを使用する場合

  • React アプリでグローバル状態管理を設定する場合
  • Redux Toolkit、Zustand、Jotai のいずれかを選ぶ場合
  • React Query または SWR でサーバー状態を管理する場合
  • 楽観的更新を実装する場合
  • 状態に関連する問題をデバッグする場合
  • レガシー Redux から最新のパターンに移行する場合

基本概念

1. 状態のカテゴリ
種類説明ソリューション
ローカル状態コンポーネント固有の UI 状態useState、useReducer
グローバル状態コンポーネント間で共有される状態Redux Toolkit、Zustand、Jotai
サーバー状態リモートデータ、キャッシュReact Query、SWR、RTK Query
URL 状態ルートパラメーター、検索React Router、nuqs
フォーム状態入力値、バリデーションReact Hook Form、Formik
2. 選定基準
Small app, simple state → Zustand or Jotai
Large app, complex state → Redux Toolkit
Heavy server interaction → React Query + light client state
Atomic/granular updates → Jotai

クイックスタート

Zustand(最もシンプル)
// store/useStore.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface AppState {
  user: User | null
  theme: 'light' | 'dark'
  setUser: (user: User | null) => void
  toggleTheme: () => void
}

export const useStore = create<AppState>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        theme: 'light',
        setUser: (user) => set({ user }),
        toggleTheme: () => set((state) => ({
          theme: state.theme === 'light' ? 'dark' : 'light'
        })),
      }),
      { name: 'app-storage', partialize: (state) => ({ theme: state.theme }) }
    )
  )
)

// Usage in component
function Header() {
  const { user, theme, toggleTheme } = useStore()
  return (
    <header className={theme}>
      {user?.name}
      <button onClick={toggleTheme}>Toggle Theme</button>
    </header>
  )
}

パターン

パターン 1:TypeScript での Redux Toolkit
// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import userReducer from './slices/userSlice'
import cartReducer from './slices/cartSlice'

export const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['persist/PERSIST'],
      },
    }),
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

// Typed hooks
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'

interface User {
  id: string
  email: string
  name: string
}

interface UserState {
  current: User | null
  status: 'idle' | 'loading' | 'succeeded' | 'failed'
  error: string | null
}

const initialState: UserState = {
  current: null,
  status: 'idle',
  error: null,
}

export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId: string, { rejectWithValue }) => {
    try {
      const response = await fetch(`/api/users/${userId}`)
      if (!response.ok) throw new Error('Failed to fetch user')
      return await response.json()
    } catch (error) {
      return rejectWithValue((error as Error).message)
    }
  }
)

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    setUser: (state, action: PayloadAction<User>) => {
      state.current = action.payload
      state.status = 'succeeded'
    },
    clearUser: (state) => {
      state.current = null
      state.status = 'idle'
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.status = 'loading'
        state.error = null
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.current = action.payload
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.status = 'failed'
        state.error = action.payload as string
      })
  },
})

export const { setUser, clearUser } = userSlice.actions
export default userSlice.reducer
パターン 2:スライスを使った Zustand(スケーラブル)
// store/slices/createUserSlice.ts
import { StateCreator } from 'zustand'

export interface UserSlice {
  user: User | null
  isAuthenticated: boolean
  login: (credentials: Credentials) => Promise<void>
  logout: () => void
}

export const createUserSlice: StateCreator<
  UserSlice & CartSlice, // Combined store type
  [],
  [],
  UserSlice
> = (set, get) => ({
  user: null,
  isAuthenticated: false,
  login: async (credentials) => {
    const user = await authApi.login(credentials)
    set({ user, isAuthenticated: true })
  },
  logout: () => {
    set({ user: null, isAuthenticated: false })
    // Can access other slices
    // get().clearCart()
  },
})

// store/index.ts
import { create } from 'zustand'
import { createUserSlice, UserSlice } from './slices/createUserSlice'
import { createCartSlice, CartSlice } from './slices/createCartSlice'

type StoreState = UserSlice & CartSlice

export const useStore = create<StoreState>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}))

// Selective subscriptions (prevents unnecessary re-renders)
export const useUser = () => useStore((state) => state.user)
export const useCart = () => useStore((state) => state.cart)
パターン 3:アトミック状態向けの Jotai

非同期アトムのフックを呼び出すコンポーネントの外側に Suspense を配置します(たとえば、親コンポーネントで <Profile /> を <Suspense> でラップします)。サスペンドするフックの後に返される境界では、そのサスペンドを捕捉できません。

// atoms/userAtoms.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'

// Basic atom
export const userAtom = atom<User | null>(null)

// Derived atom (computed)
export const isAuthenticatedAtom = atom((get) => get(userAtom) !== null)

// Atom with localStorage persistence
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')

// Async atom
export const userProfileAtom = atom(async (get) => {
  const user = get(userAtom)
  if (!user) return null
  const response = await fetch(`/api/users/${user.id}/profile`)
  return response.json()
})

// Write-only atom (action)
export const logoutAtom = atom(null, (get, set) => {
  set(userAtom, null)
  set(cartAtom, [])
  localStorage.removeItem('token')
})

// Usage
function Profile() {
  const [user] = useAtom(userAtom)
  const [, logout] = useAtom(logoutAtom)
  const [profile] = useAtom(userProfileAtom) // Suspense-enabled

  return (
    <Suspense fallback={<Skeleton />}>
      <ProfileContent profile={profile} onLogout={logout} />
    </Suspense>
  )
}
パターン 4:サーバー状態向けの React Query
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// Query keys factory
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
}

// Fetch hook
export function useUsers(filters: UserFilters) {
  return useQuery({
    queryKey: userKeys.list(filters),
    queryFn: () => fetchUsers(filters),
    staleTime: 5 * 60 * 1000, // 5 minutes
    gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
  })
}

// Single user hook
export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
    enabled: !!id, // Don't fetch if no id
  })
}

// Mutation with optimistic update
export function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: updateUser,
    onMutate: async (newUser) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: userKeys.detail(newUser.id) })

      // Snapshot previous value
      const previousUser = queryClient.getQueryData(userKeys.detail(newUser.id))

      // Optimistically update
      queryClient.setQueryData(userKeys.detail(newUser.id), newUser)

      return { previousUser }
    },
    onError: (err, newUser, context) => {
      // Rollback on error
      queryClient.setQueryData(
        userKeys.detail(newUser.id),
        context?.previousUser
      )
    },
    onSettled: (data, error, variables) => {
      // Refetch after mutation
      queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) })
    },
  })
}
パターン 5:クライアント状態とサーバー状態の組み合わせ
// Zustand for client state
const useUIStore = create<UIState>((set) => ({
  sidebarOpen: true,
  modal: null,
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  openModal: (modal) => set({ modal }),
  closeModal: () => set({ modal: null }),
}))

// React Query for server state
function Dashboard() {
  const { sidebarOpen, toggleSidebar } = useUIStore()
  const { data: users, isLoading } = useUsers({ active: true })
  const { data: stats } = useStats()

  if (isLoading) return <DashboardSkeleton />

  return (
    <div className={sidebarOpen ? 'with-sidebar' : ''}>
      <Sidebar open={sidebarOpen} onToggle={toggleSidebar} />
      <main>
        <StatsCards stats={stats} />
        <UserTable users={users} />
      </main>
    </div>
  )
}

これらはクライアント側の部分的なコード例です。認証情報や非公開のユーザー記録を localStorage に永続化したり、devtools を通じて公開したりしないでください。SSR ではリクエストごとに分離されたストア/キャッシュを作成し、サインアウト時にテナント/ユーザーデータを消去してください。サーバーのクエリキーには認可コンテキストを含めてください。フロントエンドの isAuthenticated フラグでは API を認可できません。取得した JSON は実行時に検証してください。楽観的更新のスナップショットは、同時に行われる更新を上書きするおそれがあります。競合する書き込みを直列化するか、サーバーの状態と照合して解決し、詳細クエリだけでなく、影響を受けるリストも無効化してください。

ベストプラクティス

推奨事項
  • 状態を近くに配置する - 状態は、できるだけ使用箇所の近くに保ちます
  • セレクターを使う - 選択的なサブスクリプションにより、不要な再レンダリングを防ぎます
  • データを正規化する - 入れ子構造をフラット化し、更新しやすくします
  • すべてに型を付ける - TypeScript を全面的に適用し、実行時エラーを防ぎます
  • 関心事を分離する - サーバー状態(React Query)とクライアント状態(Zustand)を分けます
非推奨事項
  • グローバル化しすぎない - すべてをグローバル状態にする必要はありません
  • サーバー状態を重複させない - React Query に管理させます
  • 直接変更しない - 常にイミュータブルな更新を使用します
  • 派生データを保存しない - 保存する代わりに計算します
  • パラダイムを混在させない - カテゴリごとに主要なソリューションを 1 つ選びます

移行ガイド

レガシー Redux から RTK への移行
// Before (legacy Redux)
const ADD_TODO = 'ADD_TODO'
const addTodo = (text) => ({ type: ADD_TODO, payload: text })
function todosReducer(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [...state, { text: action.payload, completed: false }]
    default:
      return state
  }
}

// After (Redux Toolkit)
const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as { text: string; completed: boolean }[],
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      // Immer allows "mutations"
      state.push({ text: action.payload, completed: false })
    },
  },
})

リソース

制限事項

  • タスクが上記の説明にある範囲に明確に該当する場合にのみ、このスキルを使用してください。
  • 出力を、環境固有の検証、テスト、専門家によるレビューの代わりとして扱わないでください。
  • 必要な入力、権限、安全上の境界、または成功基準が不足している場合は、作業を中断して確認を求めてください。

コメント