用 reaction hook 讓副作用各自帶依賴

主張:要解掉 共用能力 context 會隨副作用種類膨脹,把每個反應做成自帶依賴的 reaction hook,再抽一個共用樣板 hook 收掉去重/守衛的重複邏輯。「拿依賴」的責任從中央 dispatcher 還給各家,共用 ctx 就不必再當聯集。

骨架

① 共用樣板——不認得任何 concrete workflow,只留 seam:

// useClosedJobReaction.ts
export const useClosedJobReaction = (
  projectId: string | undefined,
  opts: {
    workflowTypes: string[];                    // 支援多 type 共用(bulk 兩個 workflow)
    shouldHandle: (job: InFlightJobItem) => boolean;
    onHandle: (job: InFlightJobItem) => void;
  }
) => {
  const { data } = useProjectInFlightJobs(projectId);
  useEffect(() => {
    data?.closedJobs?.forEach((job) => {
      if (!opts.workflowTypes.includes(job.workflowType) || !opts.shouldHandle(job)) return;
      if (hasHandledJob(job.runId)) return;
      markJobHandled(job.runId);                // ★ 先 mark 再 run,仍擋 re-entrant
      opts.onHandle(job);
    });
  }, [data?.closedJobs, projectId]);
};

② 每個反應只拿自己要的依賴——沒有聯集、沒有共用 ctx:

export const useTransportationSegmentsReaction = (projectId?: string) => {
  const { showToast, closeToast } = useNewTransportationSegmentsToast();  // 只 import 自己要的
  const shown = useRef<Set<string>>(new Set());
  useClosedJobReaction(projectId, {
    workflowTypes: [TRANSPORTATION_AGGREGATE_WORKFLOW],
    shouldHandle: (j) => j.status === COMPLETED && j.hasNewTransportationSegments === true,
    onHandle: (j) => { showToast(j.runId); shown.current.add(j.runId); },
  });
  useEffect(() => () => {                        // 切換專案關 toast 的政策,留在這個反應自己身上
    shown.current.forEach(closeToast); shown.current.clear();
  }, [projectId, closeToast]);
};

③ 掛載點是一份清單

useTransportationSegmentsReaction(projectId);
useBulkCreatedReaction(projectId);
useXxxApiReaction(projectId);   // 打 API 的新 workflow:內部自己 useXxxApi(),別人不受影響

為什麼不再膨脹

共用 ctxreaction hook
依賴誰拿dispatcher 全拿、塞進聯集每個反應只拿自己要的
介面一個聯集介面(違反 ISP)沒有共用介面,天生隔離
加一種副作用撐大 ctx + dispatcher新增一檔、掛一行,既有不動
中央耦合dispatcher 認得全部沒有中央點

去重、job 來源、樣板邏輯仍共用useClosedJobReaction + closedJobHandledStorage),不是退回重複程式碼——只是把「拿依賴」從中央搬回各家。

附帶好處:消掉「框架內 vs 框架外」的分裂

原本 useAggregationCompletionFactorToast 因為要 async diff 被刻意留在框架外。改成這個模型後全部都是 reaction hook:單純的用共用樣板、複雜/async 的就不用樣板自己寫 effect,那條人為界線消失。

取捨

  • 失去 CLOSED_JOB_HANDLERS 那張「一眼看完所有反應」的中央目錄,換成掛載點一份 hook 清單。
  • 掛載點一種反應一行——但這誠實反映「你就是在 mount N 個獨立反應」。
  • 若很想留中央目錄:可把註冊表改成 Record<type, () => Runtime>(值是 hook),用 Object.values(REGISTRY).forEach(useIt) 迭代;registry 是 module 常數、順序固定不違反 rules of hooks,但 ESLint hooks plugin 會叫、也較 magic。