状態管理設計
State Management Design

1. 状態管理の方針
State Management Policy

状態を 4 種類に分類し、適切なツールで管理する。

Server State — Remote
TanStack Query
@tanstack/react-query
用語一覧データ、総件数
自動キャッシュ・再フェッチ・楽観的更新
Server State — Lightweight
useState + fetch
useAdminSession
管理者セッション状態
QueryCache を経由しない軽量実装
Global UI State
Jotai Atoms
jotai
検索キーワード・モード・カテゴリ
現在ページ・表示形式・コマンドパレット開閉
Local UI State
React useState
useState
モーダルの開閉、フォーム入力値
詳細ダイアログ対象 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 FormuseForm + 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 → 表示コンポーネントの順にデータが流れる