16 KiB
16 KiB
狀態管理架構指南
🎯 狀態管理策略
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
}
⚡ 性能優化建議
- 使用選擇器避免不必要的重新渲染
// ✅ 只訂閱需要的狀態
const username = useStore(state => state.user?.name)
- 適當使用 memo 和 useMemo
const expensiveValue = useMemo(() =>
calculateExpensive(data), [data]
)
- 分割大型 Store
// 將不相關的狀態分離到不同的 store
const useAuthStore = create(...)
const useUIStore = create(...)
- 使用 Suspense 處理載入狀態
<Suspense fallback={<Loading />}>
<FlashcardList />
</Suspense>