662 lines
16 KiB
Markdown
662 lines
16 KiB
Markdown
# 狀態管理架構指南
|
||
|
||
## 🎯 狀態管理策略
|
||
|
||
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>
|
||
``` |