從 Custom Hook 到 Zustand:React 狀態共享的三種做法
2025-06-29 22:53:05
> 當我們在開發 React 應用時,常會不自覺地掉入一個陷阱:**以為把 state 包進 custom hook,就可以實現狀態共享。**
> 事實上,這樣做只會讓每個使用該 hook 的元件擁有「**各自獨立的 state**」,彼此無法同步。
## 什麼是 Custom Hook?
Custom Hook 是 React 提供的強大抽象機制,讓我們能將元件邏輯封裝成可重用的函式。
以下是一個基本的計數器 hook 範例:
```tsx
// useCounter.ts
import { useState } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount((c) => c + 1)
const decrement = () => setCount((c) => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
```
```tsx
// Counter.tsx
import { useCounter } from './useCounter'
export function Counter() {
const { count, increment, decrement, reset } = useCounter(5)
return (
<div>
<p>目前計數:{count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>重設</button>
</div>
)
}
```
透過 `useCounter`,我們成功把計數邏輯與 UI 呈現分離,使程式碼更具模組性、可測試性與重複使用性。
## 但 Custom Hook 可以共享狀態嗎?
假設你這樣寫:
```tsx
// useSearchOptions.ts
function useSearchOptions() {
const [isEnabled, setIsEnabled] = useState(false)
return { isEnabled, setIsEnabled }
}
// ComponentA.tsx
const { isEnabled } = useSearchOptions()
// ComponentB.tsx
const { isEnabled } = useSearchOptions()
```
### ❌ 答案是:**無法共享**。
即使兩個元件都呼叫 `useSearchOptions()`,但每次 hook 執行時,都會建立一個全新的 state 實例。
也就是說,**`ComponentA` 和 `ComponentB` 各自擁有獨立的 isEnabled**,互不干涉。
🔔 **Hook 是邏輯抽象,不是共享工具!**
## 正確的狀態共享方式
### 1. 父層 state + props 傳遞
```tsx
// Parent.tsx
function Parent() {
const [isEnabled, setIsEnabled] = useState(false)
return (
<div>
<ComponentA isEnabled={isEnabled} />
<ComponentB setIsEnabled={setIsEnabled} />
</div>
)
}
// ComponentA.tsx
function ComponentA({ isEnabled }: { isEnabled: boolean }) {
return <div>A: {isEnabled.toString()}</div>
}
// ComponentB.tsx
function ComponentB({ setIsEnabled }: { setIsEnabled: (v: boolean) => void }) {
return <button onClick={() => setIsEnabled(true)}>開啟</button>
}
```
✅ 優點:簡單直覺、符合 React 單向資料流
❌ 缺點:會產生 `props drilling`,不適合層級複雜的應用
### 2. 使用 React Context
```tsx
// SearchContext.tsx
import { createContext, useContext, useState } from 'react'
type SearchContextType = {
isEnabled: boolean
setIsEnabled: (v: boolean) => void
}
const SearchContext = createContext<SearchContextType | null>(null)
export function SearchProvider({ children }: { children: React.ReactNode }) {
const [isEnabled, setIsEnabled] = useState(false)
return (
<SearchContext.Provider value={{ isEnabled, setIsEnabled }}>
{children}
</SearchContext.Provider>
)
}
export function useSearchContext() {
const ctx = useContext(SearchContext)
if (!ctx) throw new Error('useSearchContext 必須在 SearchProvider 中使用')
return ctx
}
```
```tsx
// ComponentA.tsx
function ComponentA() {
const { isEnabled } = useSearchContext()
return <div>A: {isEnabled.toString()}</div>
}
// ComponentB.tsx
function ComponentB() {
const { setIsEnabled } = useSearchContext()
return <button onClick={() => setIsEnabled(true)}>開啟</button>
}
```
✅ 優點:解決 props drilling 問題,可跨層級共用狀態
❌ 缺點:只要 `value` 改變,整個 provider 下的元件都會 re-render(效能瓶頸)
## Zustand:現代、輕量的狀態共享方案
[Zustand](https://zustand-demo.pmnd.rs/),語法像 hook,效能媲美 Redux,卻無需寫 reducer 或 context。
### 安裝
```bash
npm install zustand
```
### Zustand 實作 Store
```ts
// useSearchStore.ts
import { create } from 'zustand'
export const useSearchStore = create((set) => ({
isEnabled: false,
setIsEnabled: (v: boolean) => set({ isEnabled: v }),
}))
```
### 使用 Store
```tsx
// ComponentA.tsx
function ComponentA() {
const isEnabled = useSearchStore((s) => s.isEnabled)
return <div>A: {isEnabled.toString()}</div>
}
// ComponentB.tsx
function ComponentB() {
const setIsEnabled = useSearchStore((s) => s.setIsEnabled)
return <button onClick={() => setIsEnabled(true)}>開啟</button>
}
```
✅ 只有 `ComponentA` 訂閱了 `isEnabled`,狀態變更時只有它會 re-render,`ComponentB` 不會受到影響。
---
## Zustand 重構 Custom Hook 實例
假設你之前寫了一個封裝邏輯的 custom hook,現在可以這樣改寫成 Zustand 的版本:
### 原本 Custom Hook
```ts
function useSearchOptions() {
const [isEnabled, setIsEnabled] = useState(false)
return { isEnabled, setIsEnabled }
}
```
### Zustand 重構
```ts
// useSearchStore.ts
import { create } from 'zustand'
export const useSearchStore = create((set) => ({
isEnabled: false,
setIsEnabled: (v: boolean) => set({ isEnabled: v }),
}))
```
你也可以包裝成一樣的 API:
```ts
export function useSearchOptions() {
const isEnabled = useSearchStore((s) => s.isEnabled)
const setIsEnabled = useSearchStore((s) => s.setIsEnabled)
return { isEnabled, setIsEnabled }
}
```
這樣就能在原本使用 `useSearchOptions` 的元件中無痛切換到 Zustand。
## 三種狀態共享方式比較表
| 方法 | 優點 | 缺點 | 適用場景 |
| ------------- | ---------------------- | -------------------------- | --------- |
| Props 傳遞 | 簡單直觀,資料流清晰 | props drilling,耦合性高 | 小型應用,局部共享 |
| React Context | 跨層級元件共享方便 | Provider 下所有元件都會 re-render | 中大型應用 |
| Zustand | 輕量精準訂閱、效能佳、無需 Provider | 額外學習曲線,需導入外部套件 | 中大型、複雜邏輯 |
## 總結:Custom Hook ≠ 狀態共享
曾經我在開發中誤以為 hook 是共享的,導致跨元件資料不同步的 bug。這次經驗讓我重新檢視:
> **「在 React 中,應該如何正確設計狀態共享邏輯?」**
以下是我歸納的結論:
* Custom Hook 用來抽象邏輯,不應該用來共享 state
* 小範圍可用 props,跨層級可用 context
* 複雜應用推薦使用 Zustand,效能與開發體驗兼具
## 參考資料:
React: https://react.dev/
Zustand: https://zustand.docs.pmnd.rs/getting-started/introduction
點擊複製文章連結