react-state-management について
react-state-management は、React アプリケーションの状態管理に取り組む開発者向けのガイドです。クライアントとサーバーの状態管理手法の選定、実装パターン、実践上の指針、旧式 Redux からの移行を扱います。
主な機能
- 01ローカル、グローバル、サーバー、URL、フォーム状態の区別
- 02Redux Toolkit、Zustand、Jotai の比較
- 03React Query または SWR によるサーバー状態管理
- 04クライアントとサーバー状態のパターン適用
- 05旧式 Redux の移行ガイド
活用シーン
- 01React アプリの状態管理方式の選定
- 02楽観的更新の実装
- 03状態関連の問題のデバッグ
- 04旧式 Redux 実装の刷新
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ rmyndharis/ antigravity-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' }
)
)
)
// 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
// 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>
)
}
ベストプラクティス
推奨事項
- 状態を近くに配置する - 状態を、可能な限り使用箇所の近くに保ちます
- セレクターを使う - 必要なものだけを購読し、不要な再レンダリングを防ぎます
- データを正規化する - ネストした構造をフラットにして、更新しやすくします
- すべてに型を付ける - 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: [],
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
// Immer allows "mutations"
state.push({ text: action.payload, completed: false })
},
},
})
リソース
コメント
Markdown 対応