HeyBinyang

自訂 Hook 的設計與拆分——從複用邏輯到複用心智模型

·技術·visibility1 閱讀 ·React

很多人第一次自訂 Hook,出發點都差不多:

  • 元件裡有三四個 useState

  • 多個 useEffect

  • 還有一點事件處理邏輯

  • 程式碼越來越長,看著有些累

於是順手抽出一個 useXxx.ts,把程式碼搬過去。 檔案是變短了,但新的問題很快又來了:

  • 這個 Hook 的輸入很多,回傳值也很多。

  • 邏輯雖然搬走了,但讀起來還是亂。

  • 子元件呼叫時,不知道哪些回傳值是「狀態」,哪些是「動作」,哪些只是內部實作。

這說明問題不在「有沒有抽 Hook」,而在於:

自訂 Hook 不是「把程式碼換個檔案放」,而是把一段狀態邏輯重新設計成一個穩定的介面。

如果元件是 UI 的組合單位,那自訂 Hook 就是狀態邏輯的組合單位

一、先別急著抽:什麼時候值得做成自訂 Hook?

React 官方對 custom hook 的定義很樸素:

  • 它是一個以 use 開頭的函式;

  • 它內部可以呼叫其他 Hook;

  • 它的目的,是在多個元件之間複用有狀態邏輯。

但在日常專案裡,「複用」只是其中一種理由。 真正常見的抽取時機,通常有三類:

1. 同一段狀態邏輯在多個元件裡重複出現

比如:

  • useWindowSize

  • useOnlineStatus

  • useDebouncedValue

  • useFetchUser

這種最直觀,就是典型的邏輯複用。

2. 一個元件裡的 Hooks 組合已經形成了「一個完整能力」

比如:

  • 搜尋輸入 + 防抖 + 請求 + 取消舊請求

  • 彈窗開關 + ESC 關閉 + 點擊外部關閉

  • 表單值 + 校驗 + 提交 + 重置

這時候即使暫時只有一個元件用,也值得抽。 因為你不是為了「複用程式碼行數」,而是在複用一塊完整的互動語意。

3. 你想把元件從「業務控制器」還原成「UI 描述」

這類是我覺得最值得抽的情況。

很多元件一開始是這樣的:

  • 上面一排 useState

  • 中間三段 useEffect

  • 下面五個 handler

  • 最後才是 JSX

讀起來像一個微型控制器,而不是一個視圖。 這時候抽 Hook,不是為了炫技,而是為了讓元件重新變回「展示層」。

二、自訂 Hook 抽的不是程式碼,而是「問題邊界」

這是整篇最重要的一句。

很多人抽 Hook 時,容易按「技術點」拆:

  • 一個 Hook 管 state

  • 一個 Hook 管 effect

  • 一個 Hook 管 callback

結果拆出來一堆 useAuseBuseC,誰都看不懂。

更好的方式是按「問題邊界」拆,也就是按業務上要解決的那件事拆。

比如你做一個搜尋面板,不要拆成:

  • useKeywordState

  • useDebounce

  • useSearchEffect

而更應該優先思考:

  • 這段邏輯整體上是不是「搜尋能力」?

  • 這個能力對外最重要的輸入和輸出是什麼?

於是你就得到一個更語意化的介面:

const {
  query,
  setQuery,
  results,
  loading,
  error,
} = useSearchUsers();

這樣一來,呼叫方不需要知道內部到底用了 useEffectuseRef 還是 useCallback。 它只關心:這個 Hook 提供了一個「使用者搜尋能力」。

所以設計自訂 Hook 時,優先回答這兩個問題:

  1. 這個 Hook 在解決什麼完整問題?

  2. 呼叫它的元件,最少需要知道什麼?

三、一個壞例子:只是把元件程式碼搬走,不叫設計

先看一段常見的「抽了但沒完全抽」的程式碼。

元件裡原來的邏輯

function SearchPanel() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);

    fetch(`/api/users?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [query]);

  return ...
}

很多人會直接抽成:

function useSearchPanelLogic() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    ...
  }, [query]);

  return { query, setQuery, users, loading };
}

這不算錯,但也談不上「設計」。 因為它只是把原元件邏輯整塊搬到另一個檔案,名字換成了 Hook。

問題在於:

  • useSearchPanelLogic 這個名字太貼近頁面,不是能力名。

  • 如果以後別的地方也要「搜尋使用者」,這個 Hook 可能並不通用。

  • 它把「頁面」和「能力」綁在一起了。

更好的做法,是抽成一個語意更清楚的 Hook:

function useUserSearch(initialQuery = '') {
  const [query, setQuery] = useState(initialQuery);
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (!query) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [query]);

  return {
    query,
    setQuery,
    users,
    loading,
    error,
  };
}

這時候它已經不是「頁面邏輯搬家」,而是一個真正有邊界的狀態能力模組了。

四、設計自訂 Hook 時,輸入輸出最好保持「少而穩」

自訂 Hook 最容易失控的地方,是回傳值越來越多。

比如:

const {
  query,
  setQuery,
  users,
  setUsers,
  loading,
  setLoading,
  error,
  setError,
  refetch,
  reset,
  inputRef,
} = useUserSearch();

這類 API 一看就有點不對勁。 原因不是「值太多」本身,而是它把內部狀態管理權也暴露出去了。

一個 Hook 設計得越成熟,對外暴露的東西通常越像兩類:

  • 狀態

  • 動作

比如:

const {
  query,
  users,
  loading,
  error,
  setQuery,
  refetch,
  reset,
} = useUserSearch();

這裡就很清楚:

  • query/users/loading/error 是狀態。

  • setQuery/refetch/reset 是動作。

而像 setUserssetLoadingsetError 這種,一般都屬於 Hook 內部實作,不應該隨便暴露給呼叫者。 否則這個 Hook 就失去了自己的「約束力」,外部可以隨意把它狀態改亂。

所以有個很實用的設計原則: > Hook 的回傳值,優先暴露「可讀狀態」和「語意動作」,少暴露內部 setter。

這和元件封裝是一個道理。

五、把前幾篇講過的規則,真正沉澱進 Hook 裡

自訂 Hook 最有價值的地方,不是「複用程式碼」,而是把前面幾篇的最佳實踐,沉澱成團隊可複用的預設寫法。

也就是說,一個好的自訂 Hook,應該天然具備這些特徵:

  • 不濫用 effect。

  • 不同步派生狀態。

  • 處理好閉包和依賴陣列。

  • 只在必要時使用 ref 和 callback。

比如我們前面講過「防抖搜尋」,如果每個元件都自己手寫一遍:

  • 有的人會忘記清理 timer;

  • 有的人會寫出 stale closure;

  • 有的人會把中間狀態同步成一團。

這時候把它封成 Hook,其實是在統一一套正確做法:

function useDebouncedValue<T>(value: T, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(id);
  }, [value, delay]);

  return debouncedValue;
}

然後業務元件就可以寫成:

const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);

這樣,呼叫者根本不需要重新思考防抖 effect 的邊界問題。 這就是自訂 Hook 的真正價值:把判斷成本前置一次,後面的人只管用。

六、3 個最值得在專案裡沉澱的基礎 Hook

這一節不追求花俏,講三個最容易落地、也最能體現設計感的 Hook。

1. usePrevious

function usePrevious<T>(value: T) {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

這個 Hook 很適合做對比邏輯,比如:

  • 判斷值是否剛剛變化

  • 做簡單動畫觸發

  • 對比前後 props

它的意義不是程式碼複用本身,而是把「前一個值」的語意提煉出來。

2. useDebouncedValue

function useDebouncedValue<T>(value: T, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(id);
  }, [value, delay]);

  return debouncedValue;
}

這個 Hook 非常適合:

  • 搜尋框

  • 表單連動

  • 頻繁輸入後的延遲請求

它把「防抖的時間語意」從元件裡抽掉了。

3. useEventCallback / latest-callback 模式

function useEventCallback<T extends (...args: any[]) => any>(fn: T) {
  const fnRef = useRef(fn);

  useEffect(() => {
    fnRef.current = fn;
  }, [fn]);

  return useCallback((...args: Parameters<T>) => {
    return fnRef.current(...args);
  }, []);
}

這個 Hook 的場景是:

  • 你想拿到一個穩定函式參考;

  • 它內部始終執行最新邏輯。

這類模式特別適合第三方事件系統、定時器工具、訂閱橋接。

不過這裡也要提醒一句:

  • 它是工具模式,不是業務元件預設寫法。

  • 普通場景老實寫依賴,往往更簡單。

七、什麼時候不該抽自訂 Hook?

這一節很重要。 不是元件裡出現了兩個 useState,就必須抽 Hook。

下面幾種情況,通常不值得抽:

1. 邏輯非常短,而且只在當前元件裡出現一次

比如:

const [open, setOpen] = useState(false);

這種抽成 useModalOpen() 反而畫蛇添足。 因為它沒有形成一個真正的能力邊界,只是把簡單邏輯包了一層名字。

2. 抽出去之後,介面比原元件還難理解

如果一個 Hook 需要:

  • 8 個參數

  • 12 個回傳值

  • 一堆 options 配置

那你大概率不是在抽象,而是在把複雜度隱藏起來。 複雜度沒有消失,只是換了個地方。

3. 邏輯和頁面結構強綁定,暫時看不出穩定邊界

有些東西現在就只屬於這個頁面,而且你還沒摸清它最終會怎麼演化。 這時候寧可先留在元件裡,也別急著抽一個半成品 Hook。

抽 Hook 的時機,通常是「邊界已經浮現」,不是「程式碼剛一變長就趕緊拆」。

八、一個完整重構範例:從頁面控制器到自訂 Hook

先看一個比較常見的頁面程式碼:

function UserSearchPage() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const debouncedQuery = useDebouncedValue(query, 400);

  useEffect(() => {
    if (!debouncedQuery) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${debouncedQuery}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [debouncedQuery]);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      {loading && <div>Loading...</div>}
      {error && <div>{error.message}</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

這段還算清楚,但隨著需求變多,很快就會變厚。 這時候可以抽成:

function useUserSearch(delay = 400) {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const debouncedQuery = useDebouncedValue(query, delay);

  useEffect(() => {
    if (!debouncedQuery) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${debouncedQuery}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [debouncedQuery]);

  const reset = useCallback(() => {
    setQuery('');
    setUsers([]);
    setError(null);
    setLoading(false);
  }, []);

  return {
    query,
    setQuery,
    users,
    loading,
    error,
    reset,
  };
}

然後頁面變成:

function UserSearchPage() {
  const {
    query,
    setQuery,
    users,
    loading,
    error,
    reset,
  } = useUserSearch();

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      <button onClick={reset}>清空</button>
      {loading && <div>Loading...</div>}
      {error && <div>{error.message}</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

這樣改完最大的好處不是「程式碼少了」,而是頁面終於重新像頁面了:

  • 它負責展示。

  • useUserSearch 負責狀態能力。

這才是一個元件 + Hook 的自然分工。

九、這一篇可以帶走的結論

  1. 自訂 Hook 的目標不是單純複用程式碼,而是複用一段狀態邏輯的邊界和語意。

  2. 該抽 Hook 的時機,通常是邏輯已經形成一個完整能力,或者你想把元件重新還原成展示層。

  3. 抽 Hook 時要按「問題邊界」拆,而不是按 state/effect/callback 這種技術點拆。

  4. Hook 對外優先暴露「狀態 + 動作」,少暴露內部 setter。

  5. 一個好的自訂 Hook,應該把前面幾篇講過的正確心智模型沉澱進去,而不是把複雜度原樣搬家。

分享