這篇接續上一篇的結尾,讓我們來透過實際的範例,理解要怎麼互補使用。
首先,我們先來釐清主要的概念:
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 下的讀取值一致性,給出具體「可以直接抄」的模式與檢查表。