dramaling-vocab-learning/docs/03_development/state-management.md

16 KiB
Raw Blame History

狀態管理架構指南

🎯 狀態管理策略

DramaLing 採用分層狀態管理策略:

┌─────────────────────────────────────┐
│         Global State (Zustand)       │ ← 用戶資料、主題設定
├─────────────────────────────────────┤
│      Server State (TanStack Query)   │ ← API 數據、快取
├─────────────────────────────────────┤
│       Component State (useState)     │ ← UI 狀態、表單
└─────────────────────────────────────┘

📦 技術選型

狀態類型 工具 使用場景
全局狀態 Zustand 用戶認證、主題、設定
服務端狀態 TanStack Query API 數據、快取管理
表單狀態 React Hook Form 複雜表單驗證
組件狀態 useState/useReducer 簡單 UI 狀態

🔧 安裝配置

# 狀態管理核心
npm install zustand
npm install @tanstack/react-query
npm install react-hook-form zod
npm install @hookform/resolvers

🏗️ Zustand 全局狀態

1. Store 結構

// src/store/index.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { createAuthSlice, AuthSlice } from './slices/authSlice'
import { createSettingsSlice, SettingsSlice } from './slices/settingsSlice'
import { createFlashcardSlice, FlashcardSlice } from './slices/flashcardSlice'

export type StoreState = AuthSlice & SettingsSlice & FlashcardSlice

export const useStore = create<StoreState>()(
  devtools(
    persist(
      (...a) => ({
        ...createAuthSlice(...a),
        ...createSettingsSlice(...a),
        ...createFlashcardSlice(...a),
      }),
      {
        name: 'dramaling-storage',
        partialize: (state) => ({
          // 只持久化部分數據
          theme: state.theme,
          language: state.language,
        }),
      }
    )
  )
)

2. Auth Slice 範例

// src/store/slices/authSlice.ts
import { StateCreator } from 'zustand'
import { User } from '@/types'

export interface AuthSlice {
  // State
  user: User | null
  isAuthenticated: boolean
  isLoading: boolean

  // Actions
  setUser: (user: User | null) => void
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
  checkAuth: () => Promise<void>
}

export const createAuthSlice: StateCreator<AuthSlice> = (set, get) => ({
  user: null,
  isAuthenticated: false,
  isLoading: true,

  setUser: (user) => {
    set({ user, isAuthenticated: !!user })
  },

  login: async (email, password) => {
    set({ isLoading: true })
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      })

      if (!response.ok) throw new Error('Login failed')

      const { user, token } = await response.json()
      localStorage.setItem('token', token)
      set({ user, isAuthenticated: true })
    } catch (error) {
      console.error('Login error:', error)
      throw error
    } finally {
      set({ isLoading: false })
    }
  },

  logout: async () => {
    try {
      await fetch('/api/auth/logout', { method: 'POST' })
      localStorage.removeItem('token')
      set({ user: null, isAuthenticated: false })
    } catch (error) {
      console.error('Logout error:', error)
    }
  },

  checkAuth: async () => {
    const token = localStorage.getItem('token')
    if (!token) {
      set({ isLoading: false, isAuthenticated: false })
      return
    }

    try {
      const response = await fetch('/api/auth/me', {
        headers: { Authorization: `Bearer ${token}` },
      })

      if (response.ok) {
        const user = await response.json()
        set({ user, isAuthenticated: true })
      } else {
        localStorage.removeItem('token')
        set({ user: null, isAuthenticated: false })
      }
    } catch (error) {
      console.error('Auth check error:', error)
    } finally {
      set({ isLoading: false })
    }
  },
})

3. 使用 Store

// src/components/Header.tsx
import { useStore } from '@/store'

export function Header() {
  const { user, logout, isAuthenticated } = useStore()

  return (
    <header>
      {isAuthenticated ? (
        <div>
          <span>Welcome, {user?.name}</span>
          <button onClick={logout}>Logout</button>
        </div>
      ) : (
        <Link href="/login">Login</Link>
      )}
    </header>
  )
}

🔄 TanStack Query 服務端狀態

1. Query Client 配置

// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 分鐘
      gcTime: 1000 * 60 * 10, // 10 分鐘(原 cacheTime
      retry: 3,
      refetchOnWindowFocus: false,
    },
    mutations: {
      retry: 1,
    },
  },
})

2. Provider 設置

// src/app/providers.tsx
'use client'

import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { queryClient } from '@/lib/query-client'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

3. API Hooks

// src/hooks/api/useFlashcards.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { flashcardApi } from '@/lib/api/flashcards'

// 查詢 Hook
export function useFlashcards(deckId?: string) {
  return useQuery({
    queryKey: ['flashcards', deckId],
    queryFn: () => flashcardApi.getFlashcards(deckId),
    enabled: !!deckId,
  })
}

// 創建 Hook
export function useCreateFlashcard() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: flashcardApi.createFlashcard,
    onSuccess: (data, variables) => {
      // 更新快取
      queryClient.invalidateQueries({
        queryKey: ['flashcards', variables.deckId]
      })

      // 樂觀更新
      queryClient.setQueryData(
        ['flashcards', variables.deckId],
        (oldData: any) => [...oldData, data]
      )
    },
  })
}

// 批量操作 Hook
export function useGenerateFlashcards() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (text: string) => {
      const response = await fetch('/api/flashcards/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      })

      if (!response.ok) throw new Error('Generation failed')
      return response.json()
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['flashcards'] })
    },
  })
}

4. 使用 Query Hooks

// src/app/flashcards/page.tsx
'use client'

import { useFlashcards, useCreateFlashcard } from '@/hooks/api/useFlashcards'

export default function FlashcardsPage() {
  const { data: flashcards, isLoading, error } = useFlashcards('deck-1')
  const createMutation = useCreateFlashcard()

  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error} />

  const handleCreate = async (data: FlashcardInput) => {
    try {
      await createMutation.mutateAsync(data)
      toast.success('Flashcard created!')
    } catch (error) {
      toast.error('Failed to create flashcard')
    }
  }

  return (
    <div>
      {flashcards?.map(card => (
        <FlashCard key={card.id} card={card} />
      ))}

      <CreateFlashcardForm
        onSubmit={handleCreate}
        isLoading={createMutation.isPending}
      />
    </div>
  )
}

📝 React Hook Form 表單狀態

1. 表單配置與驗證

// src/components/forms/FlashcardForm.tsx
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// 驗證 Schema
const flashcardSchema = z.object({
  word: z.string().min(1, 'Word is required'),
  translation: z.string().min(1, 'Translation is required'),
  example: z.string().optional(),
  difficulty: z.enum(['easy', 'medium', 'hard']),
  tags: z.array(z.string()).optional(),
})

type FlashcardFormData = z.infer<typeof flashcardSchema>

export function FlashcardForm({ onSubmit }: { onSubmit: (data: FlashcardFormData) => void }) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
    watch,
    setValue,
  } = useForm<FlashcardFormData>({
    resolver: zodResolver(flashcardSchema),
    defaultValues: {
      difficulty: 'medium',
      tags: [],
    },
  })

  // 監聽特定欄位
  const difficulty = watch('difficulty')

  const onFormSubmit = async (data: FlashcardFormData) => {
    await onSubmit(data)
    reset() // 重置表單
  }

  return (
    <form onSubmit={handleSubmit(onFormSubmit)}>
      <div>
        <input
          {...register('word')}
          placeholder="Enter word"
          className={errors.word ? 'error' : ''}
        />
        {errors.word && <span>{errors.word.message}</span>}
      </div>

      <div>
        <input
          {...register('translation')}
          placeholder="Enter translation"
        />
        {errors.translation && <span>{errors.translation.message}</span>}
      </div>

      <div>
        <textarea
          {...register('example')}
          placeholder="Example sentence"
        />
      </div>

      <div>
        <select {...register('difficulty')}>
          <option value="easy">Easy</option>
          <option value="medium">Medium</option>
          <option value="hard">Hard</option>
        </select>
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Flashcard'}
      </button>
    </form>
  )
}

2. 複雜表單範例

// src/components/forms/GenerateFlashcardsForm.tsx
import { useFieldArray, useForm } from 'react-hook-form'

interface GenerateFormData {
  sourceText: string
  settings: {
    count: number
    difficulty: string
    includeExamples: boolean
  }
  customWords: Array<{ word: string; required: boolean }>
}

export function GenerateFlashcardsForm() {
  const { control, register, handleSubmit, watch } = useForm<GenerateFormData>({
    defaultValues: {
      settings: {
        count: 10,
        difficulty: 'auto',
        includeExamples: true,
      },
      customWords: [],
    },
  })

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'customWords',
  })

  const onSubmit = (data: GenerateFormData) => {
    console.log('Generating flashcards:', data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <textarea
        {...register('sourceText', { required: true })}
        placeholder="Paste your text here..."
      />

      <div>
        <label>
          Number of cards:
          <input
            type="number"
            {...register('settings.count', { min: 1, max: 50 })}
          />
        </label>
      </div>

      <div>
        <label>
          <input
            type="checkbox"
            {...register('settings.includeExamples')}
          />
          Include example sentences
        </label>
      </div>

      <div>
        <h3>Custom Words</h3>
        {fields.map((field, index) => (
          <div key={field.id}>
            <input
              {...register(`customWords.${index}.word`)}
              placeholder="Word"
            />
            <label>
              <input
                type="checkbox"
                {...register(`customWords.${index}.required`)}
              />
              Required
            </label>
            <button type="button" onClick={() => remove(index)}>
              Remove
            </button>
          </div>
        ))}
        <button
          type="button"
          onClick={() => append({ word: '', required: false })}
        >
          Add Word
        </button>
      </div>

      <button type="submit">Generate</button>
    </form>
  )
}

🎨 組件狀態最佳實踐

1. 狀態提升原則

// ❌ 錯誤:過度使用全局狀態
function BadComponent() {
  const { modalOpen, setModalOpen } = useStore() // 不需要全局
  return <Modal open={modalOpen} />
}

// ✅ 正確:使用本地狀態
function GoodComponent() {
  const [modalOpen, setModalOpen] = useState(false)
  return <Modal open={modalOpen} onClose={() => setModalOpen(false)} />
}

2. 狀態分離

// ✅ 分離 UI 狀態和業務狀態
function FlashcardList() {
  // 業務狀態(服務端)
  const { data: flashcards } = useFlashcards()

  // UI 狀態(本地)
  const [selectedId, setSelectedId] = useState<string>()
  const [filter, setFilter] = useState('all')

  // 衍生狀態
  const filteredCards = useMemo(() => {
    if (filter === 'all') return flashcards
    return flashcards?.filter(card => card.status === filter)
  }, [flashcards, filter])

  return (
    // ...
  )
}

3. 自定義 Hook 封裝

// src/hooks/useFlashcardLearning.ts
export function useFlashcardLearning(deckId: string) {
  const { data: flashcards, isLoading } = useFlashcards(deckId)
  const [currentIndex, setCurrentIndex] = useState(0)
  const [memorized, setMemorized] = useState<Set<string>>(new Set())
  const [flipped, setFlipped] = useState(false)

  const currentCard = flashcards?.[currentIndex]
  const progress = (memorized.size / (flashcards?.length || 1)) * 100

  const nextCard = () => {
    setFlipped(false)
    setCurrentIndex(prev =>
      prev < (flashcards?.length || 0) - 1 ? prev + 1 : prev
    )
  }

  const markAsMemorized = () => {
    if (currentCard) {
      setMemorized(prev => new Set([...prev, currentCard.id]))
      nextCard()
    }
  }

  const flipCard = () => setFlipped(!flipped)

  return {
    currentCard,
    isLoading,
    flipped,
    progress,
    actions: {
      nextCard,
      markAsMemorized,
      flipCard,
    },
  }
}

📊 狀態調試工具

1. Zustand DevTools

// 開發環境自動啟用
if (process.env.NODE_ENV === 'development') {
  import('zustand/middleware').then(({ devtools }) => {
    // DevTools 會自動連接
  })
}

2. React Query DevTools

// 已在 Providers 中配置
// 按 Shift + Alt + R 開啟

3. 自定義調試 Hook

// src/hooks/useDebugState.ts
export function useDebugState<T>(name: string, initialValue: T) {
  const [state, setState] = useState<T>(initialValue)

  useEffect(() => {
    console.log(`[${name}] State updated:`, state)
  }, [name, state])

  const setDebugState = useCallback((value: T | ((prev: T) => T)) => {
    console.log(`[${name}] Setting state...`)
    setState(value)
  }, [name])

  return [state, setDebugState] as const
}

性能優化建議

  1. 使用選擇器避免不必要的重新渲染
// ✅ 只訂閱需要的狀態
const username = useStore(state => state.user?.name)
  1. 適當使用 memo 和 useMemo
const expensiveValue = useMemo(() =>
  calculateExpensive(data), [data]
)
  1. 分割大型 Store
// 將不相關的狀態分離到不同的 store
const useAuthStore = create(...)
const useUIStore = create(...)
  1. 使用 Suspense 處理載入狀態
<Suspense fallback={<Loading />}>
  <FlashcardList />
</Suspense>