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

662 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 狀態管理架構指南
## 🎯 狀態管理策略
DramaLing 採用分層狀態管理策略:
```
┌─────────────────────────────────────┐
│ Global State (Zustand) │ ← 用戶資料、主題設定
├─────────────────────────────────────┤
│ Server State (TanStack Query) │ ← API 數據、快取
├─────────────────────────────────────┤
│ Component State (useState) │ ← UI 狀態、表單
└─────────────────────────────────────┘
```
## 📦 技術選型
| 狀態類型 | 工具 | 使用場景 |
|---------|------|---------|
| **全局狀態** | Zustand | 用戶認證、主題、設定 |
| **服務端狀態** | TanStack Query | API 數據、快取管理 |
| **表單狀態** | React Hook Form | 複雜表單驗證 |
| **組件狀態** | useState/useReducer | 簡單 UI 狀態 |
## 🔧 安裝配置
```bash
# 狀態管理核心
npm install zustand
npm install @tanstack/react-query
npm install react-hook-form zod
npm install @hookform/resolvers
```
## 🏗️ Zustand 全局狀態
### 1. Store 結構
```typescript
// 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 範例
```typescript
// 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
```typescript
// 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 配置
```typescript
// 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 設置
```typescript
// 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
```typescript
// 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
```typescript
// 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. 表單配置與驗證
```typescript
// 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. 複雜表單範例
```typescript
// 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. 狀態提升原則
```typescript
// ❌ 錯誤:過度使用全局狀態
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. 狀態分離
```typescript
// ✅ 分離 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 封裝
```typescript
// 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
```typescript
// 開發環境自動啟用
if (process.env.NODE_ENV === 'development') {
import('zustand/middleware').then(({ devtools }) => {
// DevTools 會自動連接
})
}
```
### 2. React Query DevTools
```typescript
// 已在 Providers 中配置
// 按 Shift + Alt + R 開啟
```
### 3. 自定義調試 Hook
```typescript
// 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. **使用選擇器避免不必要的重新渲染**
```typescript
// ✅ 只訂閱需要的狀態
const username = useStore(state => state.user?.name)
```
2. **適當使用 memo 和 useMemo**
```typescript
const expensiveValue = useMemo(() =>
calculateExpensive(data), [data]
)
```
3. **分割大型 Store**
```typescript
// 將不相關的狀態分離到不同的 store
const useAuthStore = create(...)
const useUIStore = create(...)
```
4. **使用 Suspense 處理載入狀態**
```typescript
<Suspense fallback={<Loading />}>
<FlashcardList />
</Suspense>
```