WeHelp
從 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