状態管理設計
State Management Design
1. 状態管理の方針
State Management Policy
状態を 4 種類に分類し、適切なツールで管理する。
Server State — Remote
TanStack Query
@tanstack/react-query
用語一覧データ、総件数
自動キャッシュ・再フェッチ・楽観的更新
自動キャッシュ・再フェッチ・楽観的更新
Server State — Lightweight
useState + fetch
useAdminSession
管理者セッション状態
QueryCache を経由しない軽量実装
QueryCache を経由しない軽量実装
Global UI State
Jotai Atoms
jotai
検索キーワード・モード・カテゴリ
現在ページ・表示形式・コマンドパレット開閉
現在ページ・表示形式・コマンドパレット開閉
Local UI State
React useState
useState
モーダルの開閉、フォーム入力値
詳細ダイアログ対象 Term
詳細ダイアログ対象 Term
2. Jotai による状態管理
State Management with Jotai
2-1. Atom 定義
Atom Definitions
searchQueryAtomstring
searchModeAtom"name" | "description"
categoryFilterAtom"" | Category
currentPageAtomnumber
viewModeAtom"table" | "card"
commandPaletteOpenAtomboolean
// src/features/terms/stores/searchAtoms.ts
import type { Category } from "@/features/terms/constants/categories"
import { atom } from "jotai"
export const searchQueryAtom = atom<string>("")
export const searchModeAtom = atom<"name" | "description">("name")
export const categoryFilterAtom = atom<"" | Category>("")
export const currentPageAtom = atom<number>(1)
export const viewModeAtom = atom<"table" | "card">("table")
export const commandPaletteOpenAtom = atom<boolean>(false)
2-2. 検索・カテゴリ変化時のページリセット
Page Reset on Search/Category Change
// SearchBar.tsx
const [query, setQuery] = useAtom(searchQueryAtom)
const setPage = useSetAtom(currentPageAtom)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
setPage(1) // 検索変更時はページを 1 に戻す
}
// CategoryChipFilter.tsx
const setCategoryFilter = useSetAtom(categoryFilterAtom)
const setPage = useSetAtom(currentPageAtom)
const handleSelect = (category: "" | Category) => {
setCategoryFilter(category)
setPage(1) // カテゴリ変更時もページをリセット
}
3. TanStack Query による状態管理
State Management with TanStack Query
3-1. QueryClient 設定
QueryClient Configuration
// src/components/providers/QueryProvider.tsx
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useState } from "react"
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30秒間はキャッシュを新鮮とみなす
gcTime: 5 * 60 * 1000, // 5分間はガベージコレクションしない
retry: 1, // エラー時は1回リトライ
refetchOnWindowFocus: false, // ウィンドウフォーカス時の再フェッチを無効
},
},
})
)
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
3-2. Query Key 設計
Query Key Design
| キー | 用途 |
|---|---|
| ["terms"] | terms スコープ全体(invalidateQueries で一括無効化) |
| ["terms", "list", { q, searchBy, category, page }] | 用語一覧フェッチ |
| ["terms", "quick-search", q] | コマンドパレット検索 |
const QUERY_KEYS = {
terms: {
all: ["terms"] as const,
list: (params: { q: string; searchBy: string; category: string; page: number }) =>
["terms", "list", params] as const,
quickSearch: (q: string) => ["terms", "quick-search", q] as const,
},
}
認証状態(/api/auth/session)は QueryCache を経由せず、useAdminSession内でuseState + fetchにより管理する(軽量ユースケースのため)。
3-3. useTermList Hook(一覧取得)
useTermList Hook (List Retrieval)
// src/features/terms/api/getTerms.ts
export function useTermList() {
const rawQuery = useAtomValue(searchQueryAtom)
const searchBy = useAtomValue(searchModeAtom)
const category = useAtomValue(categoryFilterAtom)
const page = useAtomValue(currentPageAtom)
const q = useDebounce(rawQuery, 300) // 300ms デバウンス
return useQuery({
queryKey: ["terms", "list", { q, searchBy, category, page }],
queryFn: async () => {
const params = new URLSearchParams({ q, searchBy, category, page: String(page), limit: "20" })
const res = await fetch(`/api/terms?${params}`)
if (!res.ok) throw new Error("Failed to fetch terms")
return res.json()
},
placeholderData: (prev) => prev, // ページング時のちらつき防止
})
}
3-4. useMutation Hooks(CRUD 操作)
useMutation Hooks (CRUD Operations)
// useAddTerm — 用語登録
export function useAddTerm() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: TermInput) => {
const res = await fetch("/api/terms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error((await res.json()).error ?? "Failed")
return res.json()
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["terms"] }),
})
}
// useUpdateTerm — 用語更新
export function useUpdateTerm() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, data }: { id: number; data: TermInput }) => {
const res = await fetch(`/api/terms/${id}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error("Failed to update term")
return res.json()
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["terms"] }),
})
}
// useDeleteTerm — 用語削除
export function useDeleteTerm() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/terms/${id}`, { method: "DELETE" })
if (!res.ok) throw new Error("Failed to delete term")
return res.json()
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["terms"] }),
})
}
3-5. useDebounce Hook
Debounce Hook
// src/hooks/useDebounce.ts
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
3-6. useAdminSession Hook(認証状態)
useAdminSession Hook (Auth State)
// src/features/auth/hooks/useAdminSession.ts
"use client"
export function useAdminSession() {
const [isAdmin, setIsAdmin] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const refresh = useCallback(async () => {
try {
const res = await fetch("/api/auth/session", { credentials: "include" })
const data = (await res.json()) as { authenticated?: boolean }
setIsAdmin(data.authenticated === true)
} catch {
setIsAdmin(false)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => { void refresh() }, [refresh])
return { isAdmin, isLoading, refresh }
}
4. ローカル UI 状態(React useState)
Local UI State (React useState)
// GlossaryPage.tsx — ダイアログ制御
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [editingTerm, setEditingTerm] = useState<Term | null>(null)
const [deletingTermId, setDeletingTermId] = useState<number | null>(null)
const [detailTerm, setDetailTerm] = useState<Term | null>(null)
フォームの入力値は shadcn/ui Form + React Hook Form(useForm + zodResolver)を使用し、各ダイアログコンポーネント内でローカルに管理する。
5. 状態フロー全体図
State Flow Diagram
ユーザー操作から API レスポンスが画面に反映されるまでのデータの流れ。
%%{init: {'theme': 'default', 'themeVariables': {'fontSize': '13px', 'lineColor': '#9ca3af', 'edgeLabelBackground': '#fff', 'clusterBkg': '#f8fafc', 'clusterBorder': '#e2e8f0'}}}%%
flowchart TD
subgraph INPUT ["入力コントロール"]
SB["SearchBar"]
MT["SearchModeToggle"]
CF["CategoryChipFilter"]
VT["ViewToggle"]
PG["Pagination"]
end
subgraph ATOMS ["⚛ Jotai — グローバル状態"]
SQA(["searchQueryAtom"])
SMA(["searchModeAtom"])
CFA(["categoryFilterAtom"])
CPA(["currentPageAtom"])
VMA(["viewModeAtom"])
end
subgraph QUERY ["🔄 TanStack Query"]
UTL["useTermList()"]
DBC["useDebounce(300ms)"]
QC[("QueryCache")]
end
GA["GET /api/terms"]
subgraph OUTPUT ["表示コンポーネント"]
TB["TermTable"]
CG["TermCardGrid"]
end
SB -->|"searchQuery"| SQA
SB -->|"page リセット"| CPA
MT -->|"searchMode"| SMA
CF -->|"category"| CFA
CF -->|"page リセット"| CPA
PG -->|"currentPage"| CPA
VT -->|"viewMode"| VMA
SQA & SMA & CFA & CPA -->|"atom 読み取り"| UTL
UTL --> DBC --> QC
QC -->|"キャッシュミス"| GA
GA -->|"JSON"| QC
QC -->|"data"| TB & CG & PG
VMA -->|"table / card"| TB & CG
AC["AdminControls"] -->|"セッション確認"| UAS["useAdminSession()"]
UAS -->|"fetch"| GS["GET /api/auth/session"]
入力コントロール → Jotai Atoms → TanStack Query → API → 表示コンポーネントの順にデータが流れる