state-management について
state-management は、React と Next.js アプリの状態管理パターンの選択と実装を支援するスキルです。ローカル、URL、サーバー、Context、グローバル状態に加え、フォームと描画性能も扱います。
主な機能
- 01ローカル・URL・サーバー状態のパターン
- 02React Context と Zustand の指針
- 03永続化ストアとスライスストア
- 04React Hook Form のパターン
- 05ストア購読と描画の最適化
活用シーン
- 01機能に適した状態の範囲を選ぶ
- 02グローバル設定やユーザーストアの構築
- 03複雑なフォーム状態の管理
- 04不要なコンポーネント再描画の削減
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ jmsktm/ claude-settings --skill state-managementインストール後、Claude Code で/state-managementを入力して読み込まれたか確認します。
詳細
状態管理スキル
概要
このスキルでは、React/Next.js アプリケーションに適した状態管理ソリューションを選択し、実装する方法を説明します。ローカル状態からグローバルストアまで、必要なパターンを網羅しています。
状態管理の階層
1. ローカル状態(useState)
コンポーネント固有で、共有する必要のない状態に使用します。
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
2. URL 状態(useSearchParams)
URL を介して共有できる状態に使用します。
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
export function SearchFilter() {
const router = useRouter()
const searchParams = useSearchParams()
const category = searchParams.get('category') || 'all'
const setCategory = (cat: string) => {
const params = new URLSearchParams(searchParams)
params.set('category', cat)
router.push(`?${params.toString()}`)
}
return (
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="tech">Tech</option>
<option value="design">Design</option>
</select>
)
}
3. サーバー状態(Server Components)
クライアント側で変更されない、データベースや API からのデータに使用します。
// Server Component (no 'use client')
export default async function UserProfile({ userId }: { userId: string }) {
const user = await db.users.findUnique({ where: { id: userId } })
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
4. コンテキスト(React Context)
コンポーネントツリー内のシンプルなグローバル状態(テーマ、ユーザー、設定)に使用します。
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
type Theme = 'light' | 'dark'
const ThemeContext = createContext<{
theme: Theme
setTheme: (theme: Theme) => void
}>({ theme: 'light', setTheme: () => {} })
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
// Usage in component
function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
)
}
5. Zustand(グローバルストア)
多くのコンポーネントからアクセスする必要がある、複雑なグローバル状態に使用します。
基本的なストア:
// stores/user-store.ts
import { create } from 'zustand'
interface UserState {
user: User | null
isLoading: boolean
setUser: (user: User | null) => void
fetchUser: (id: string) => Promise<void>
logout: () => void
}
export const useUserStore = create<UserState>((set) => ({
user: null,
isLoading: false,
setUser: (user) => set({ user }),
fetchUser: async (id) => {
set({ isLoading: true })
try {
const response = await fetch(`/api/users/${id}`)
const user = await response.json()
set({ user, isLoading: false })
} catch (error) {
set({ isLoading: false })
}
},
logout: () => set({ user: null })
}))
// Usage in component
'use client'
import { useUserStore } from '@/stores/user-store'
export function UserProfile() {
const { user, isLoading, fetchUser } = useUserStore()
return (
<div>
{isLoading ? <p>Loading...</p> : <p>{user?.name}</p>}
</div>
)
}
永続化されたストア(localStorage):
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface PreferencesState {
theme: 'light' | 'dark'
language: string
setTheme: (theme: 'light' | 'dark') => void
setLanguage: (lang: string) => void
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
theme: 'light',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language })
}),
{
name: 'preferences-storage',
storage: createJSONStorage(() => localStorage)
}
)
)
スライス化されたストア(整理済み):
// stores/slices/auth-slice.ts
export const createAuthSlice = (set, get) => ({
token: null,
isAuthenticated: false,
login: async (credentials) => {
const token = await loginAPI(credentials)
set({ token, isAuthenticated: true })
},
logout: () => set({ token: null, isAuthenticated: false })
})
// stores/slices/cart-slice.ts
export const createCartSlice = (set, get) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id)
})),
total: () => {
const items = get().items
return items.reduce((sum, item) => sum + item.price, 0)
}
})
// stores/app-store.ts
import { create } from 'zustand'
import { createAuthSlice } from './slices/auth-slice'
import { createCartSlice } from './slices/cart-slice'
export const useAppStore = create((...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a)
}))
Immer ミドルウェア(イミュータブルな更新):
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface TodoState {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
updateTodo: (id: string, text: string) => void
}
export const useTodoStore = create<TodoState>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({ id: crypto.randomUUID(), text, done: false })
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.done = !todo.done
}),
updateTodo: (id, text) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.text = text
})
}))
)
フォーム状態の管理
React Hook Form(推奨)
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18)
})
type FormData = z.infer<typeof schema>
export function UserForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
})
const onSubmit = async (data: FormData) => {
await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register('email')} type="email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('age', { valueAsNumber: true })} type="number" />
{errors.age && <p>{errors.age.message}</p>}
<button type="submit" disabled={isSubmitting}>
Submit
</button>
</form>
)
}
パフォーマンスの最適化
ストアの選択的なサブスクリプション
// ❌ Bad - Component re-renders for any store change
function BadExample() {
const store = useUserStore()
return <div>{store.user?.name}</div>
}
// ✅ Good - Only re-renders when user.name changes
function GoodExample() {
const userName = useUserStore((state) => state.user?.name)
return <div>{userName}</div>
}
// ✅ Better - Use shallow comparison for multiple values
import { shallow } from 'zustand/shallow'
function BetterExample() {
const { user, isLoading } = useUserStore(
(state) => ({ user: state.user, isLoading: state.isLoading }),
shallow
)
return <div>{isLoading ? 'Loading...' : user?.name}</div>
}
コンポーネントに対する React.memo
import { memo } from 'react'
const ExpensiveComponent = memo(function ExpensiveComponent({
data
}: {
data: Data
}) {
// This only re-renders when data changes
return <div>{/* Expensive rendering */}</div>
})
関数に対する useCallback
'use client'
import { useCallback } from 'react'
export function Parent() {
const [count, setCount] = useState(0)
// ❌ Bad - New function on every render
const handleClick = () => {
console.log('clicked')
}
// ✅ Good - Stable function reference
const handleClickMemoized = useCallback(() => {
console.log('clicked')
}, [])
return <Child onClick={handleClickMemoized} />
}
高度なパターン
計算値
import { create } from 'zustand'
interface CartState {
items: CartItem[]
// Computed value - always fresh
total: () => number
subtotal: () => number
tax: () => number
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
total: () => {
const items = get().items
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
subtotal: () => {
return get().total()
},
tax: () => {
return get().subtotal() * 0.1
}
}))
// Usage - recalculates on every call
function Cart() {
const total = useCartStore((state) => state.total())
return <div>Total: ${total}</div>
}
非同期アクション
interface DataState {
data: Data[]
isLoading: boolean
error: string | null
fetchData: () => Promise<void>
refetch: () => Promise<void>
}
export const useDataStore = create<DataState>((set, get) => ({
data: [],
isLoading: false,
error: null,
fetchData: async () => {
set({ isLoading: true, error: null })
try {
const response = await fetch('/api/data')
const data = await response.json()
set({ data, isLoading: false })
} catch (error) {
set({ error: error.message, isLoading: false })
}
},
refetch: async () => {
await get().fetchData()
}
}))
ミドルウェアの合成
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
import { devtools } from 'zustand/middleware'
export const useStore = create<State>()(
devtools(
persist(
immer((set) => ({
// Your store
})),
{ name: 'my-store' }
)
)
)
判断ツリー
Do you need state?
├─ Only in this component? → useState
├─ Pass to 2-3 child components? → props
├─ Shareable URL? → useSearchParams
├─ From database/API?
│ ├─ Static/rarely changes? → Server Component
│ └─ Dynamic/frequent updates? → React Query/SWR
├─ Simple global (theme, user)? → Context
└─ Complex global/many subscribers? → Zustand
ベストプラクティスのチェックリスト
- ローカル状態(useState)から始める
- 共有可能なフィルターやタブには URL 状態を使用する
- DB データには Server Components を優先する
- シンプルなグローバル状態には Context を使用する
- 複雑なグローバル状態には Zustand を使用する
- 必要なストアの値だけを選択する
- 複数の値にはシャロー比較を使用する
- ユーザー設定を localStorage に永続化する
- 読み込み状態とエラー状態を処理する
- 複雑なフォームには React Hook Form を使用する
- 負荷の高い計算をメモ化する
- 型安全性のために TypeScript を使用する
避けるべきよくある間違い
- グローバル状態の過剰な使用 - まずローカル状態から始め、必要になったらグローバル状態に移行します
- ストアの値を選択しない - 不要な再レンダリングを防ぐため、必ずセレクターを使用します
- 派生値の保存 - 代わりに、その場で計算します
- 読み込み状態の未処理 - 必ずユーザーにフィードバックを表示します
- すべてを Zustand に入れる - 用途に合ったツールを使用します
このスキルを使用するタイミング
次の場合にこのスキルを呼び出します:
- 状態管理ソリューションを選択するとき
- Zustand ストアをセットアップするとき
- コンポーネントの再レンダリングを最適化するとき
- フォーム状態を管理するとき
- グローバルなユーザー設定を実装するとき
- 状態関連の問題をデバッグするとき
- Redux から Zustand に移行するとき
- 永続化された状態をセットアップするとき
コメント
Markdown 対応