這篇接續上一篇的結尾,讓我們來透過實際的範例,理解要怎麼互補使用。
首先,我們先來釐清主要的概念:
createEffect)負責資料/業務的循環工作,依賴 signal,重跑前以 onCleanup 釋放資源。useEffect / useLayoutEffect)負責UI/DOM的循環工作,依賴 React 的快照/props/state,在 下一次 commit 前清理。createEffect)intervalMs 輪詢資料,並把最新時間寫進 heartbeat signal。intervalMs 改變時,自動清掉舊的 setInterval 並重建新的。// data/heartbeat.ts
import { signal } from "../core/signal";
import { createEffect, onCleanup } from "../core/effect";
export const intervalMs = signal(1000);
export const heartbeat = signal<Date | null>(null);
createEffect(() => {
  const ms = intervalMs.get(); // 依賴 signal
  const id = setInterval(() => {
    heartbeat.set(new Date()); // 寫回資料層
  }, ms);
  onCleanup(() => clearInterval(id)); // 下一次重跑前釋放
});
createEffect?useEffect)// ui/Blinker.tsx
import { useState, useEffect } from "react";
export function Blinker({ enabled = true }) {
  const [on, setOn] = useState(false);
  useEffect(() => {
    if (!enabled) return;
    const id = setInterval(() => setOn(v => !v), 500);
    return () => clearInterval(id); // 下一次 commit 前清理
  }, [enabled]);
  return <span className={on ? "caret on" : "caret"}>|</span>;
}
useEffect?state/props,清理點應跟隨 React 的 commit 週期。// App.tsx
import { useSignalValue } from "./react-adapter";
import { heartbeat, intervalMs } from "../data/heartbeat";
import { Blinker } from "./ui/Blinker";
export function Dashboard() {
  const lastBeat = useSignalValue(heartbeat);
  const ms = useSignalValue(intervalMs);
  return (
    <section>
      <h3>Last heartbeat: {lastBeat?.toLocaleTimeString() ?? "—"}</h3>
      <p>Polling every {ms} ms</p>
      <Blinker enabled /> {/* UI 計時由 React 管 */}
    </section>
  );
}
createEffect)獨立於任何元件存在,跨頁仍持續。useEffect)隨元件掛載/卸載而建立/清除。createEffect 的 onCleanup 在重跑前執行;React useEffect 的 cleanup 在下一次 commit 前執行。createEffect(() => {
  const id = setInterval(() => {
    el.classList.toggle("on"); // DOM 時機不受 React 管
  }, 500);
  onCleanup(() => clearInterval(id));
});
更正:交給 React useEffect
(前面 Blinker 範例)
// 只 mount 一次,不會因 interval 改變而重建
React.useEffect(() => {
  const id = setInterval(fetchData,  ms /* 直接丟 signal */);
  return () => clearInterval(id);
}, []); // ← deps 空陣列
更正:兩種情況,擇一即可
// A) 使用前面封裝好的 hook,取得快照使用
const ms = useSignalValue(intervalMs);
React.useEffect(() => {
  const id = setInterval(fetchData, ms);
  return () => clearInterval(id);
}, [ms]);
// B) 回歸資料層:用 createEffect(不要在 React Component 裡面使用 signal 提供的 function 「讓他在 component 閉包之外」,讓一切回歸單純,要應用 signal 時,再透過 hook 處裡使用)
| 情境 | 建議位置 | 建立依賴 | 清理時機 | 
|---|---|---|---|
| 資料輪詢、快取更新、寫入 signal | our effect | signal.get() | 重跑前 onCleanup | 
| 視覺效果、DOM 操作、量測 | React effect | React 的 state/props | 下一次 commit 前 cleanup | 
| 同步多次 set()合併 | scheduler(內建 microtask) | — | microtask flush | 
| 讀取當前值、避免反向依賴 | useSignalValue/peek() | 不追蹤 | — | 
各自的職責
清理順序(一次更新)
Q: 為什麼 getSnapshot 用 peek() 而不是 get()?
A: 避免 React 元件本身被加入 reactive graph 的 subs;peek() 仍會在 stale 時完成 lazy 重算,快照不會舊。
Q: 我能在 render 期(function component內)使用 createEffect 嗎?
A: 盡量不要。最好還是由 adapter 的 subscribeReadable 在 useSyncExternalStore 的生命週期內建立與清理。
Q: 多次同步 set() 要手動 batch 嗎?
A: 不用。我們的 scheduler 會把同一輪更新合併到一個 microtask。跨 await 的合併將在進階篇(transaction(async))再談。
有了上面的範例之後,應該能理解結合使用 signal 的好處,用起來就和 jotai / zustand 的感覺差不多,實際上要更加精細,而且不會跟特定框架綁定,相容性更高。
下一篇,我們來針對 keys 重掛、stale closure、Transition 下的讀取值一致性,給出具體「可以直接抄」的模式與檢查表。