我們透過下面圖表,來總結一下目前應用在 React 環境中的資料流,會是怎麼處裡的:
createEffect
(business)useLayoutEffect
/ useEffect
useSignalValue
(useSyncExternalStore
+ peek()
,tear-free)前面章節已經有講過了,這裡在快速讓大家複習一下:
someSignal.get()
),就可能出現 DOM 顯示與資料快照不同。React 本身的快照機制(Snapshot),是方便他處理狀態更新與 UI 渲染交互的一種折衷解法,這個機制並不是 JavaScript 的標準,這也導致很多 React 仔會產生對 JavaScript 的錯誤認知,前面的章節也複習過一些基礎的 JavaScript 觀念了,以防中間加入的讀者會搞不清楚,這裡還是提醒一下,也可以快速讀一下這一篇。
.get()
function Bad() {
const v = someSignal.get(); // 非 React 管控的快照
return <div>{v}</div>;
}
useState
+ useEffect
處理訂閱function Bad() {
const [v, setV] = useState(someSignal.peek());
useEffect(() => {
const stop = createEffect(() => { setV(someSignal.peek()); }); // 無 commit 前重取快照
return () => stop();
}, []);
return <div>{v}</div>;
}
useSyncExternalStore
(我們的 useSignalValue
hook)function Good() {
const v = useSignalValue(someSignal); // tear-free
return <div>{v}</div>;
}
useSignalSelector
const user = signal({ id: 1, name: "Ada", age: 37 });
// 只關心 name,不因 age 改變重繪
function Name() {
const name = useSignalSelector(user, u => u.name);
return <h2>{name}</h2>;
}
get()
:只用 useSignalValue
/ useSignalSelector
。useSyncExternalStore
會在 commit 前重取快照,自帶 tear-free 保證。現象:列表 / router 切換 key 時,React 會 卸載舊樹、重掛新樹。
風險:若你的衍生值(computed / effect)長壽命且建在模組域,它的連接邊界可能仍留著。
export const expensive = computed(() => a.get() + b.get()); // 可能被多處元件讀取
// 某些頁面不再需要它,但 subs 仍存在 → 上游 a/b 仍持有連接
useComputed
function Page() {
const sum = useComputed(() => aSig.get() + bSig.get());
const v = useSignalValue(sum);
return <div>{v}</div>;
} // 卸載時 useComputed 會 dispose
const Ctx = createContext<{ sum: ReturnType<typeof computed> } | null>(null);
function StoreProvider({ children }: { children: React.ReactNode }) {
const sum = useMemo(() => computed(() => aSig.get() + bSig.get()), []);
useEffect(() => () => sum.dispose?.(), [sum]);
return <Ctx.Provider value={{ sum }}>{children}</Ctx.Provider>;
}
function Child() {
const store = useContext(Ctx)!;
const v = useSignalValue(store.sum);
return <div>{v}</div>;
}
useComputed
,讓 React 幫你收尾。computed
是反應式圖上的節點(Observer + Trackable)。它只有在 callback 裡讀到 signal.get()
才會被「誰更新 → 我要重算」這條邊追蹤到。const n = useSignalValue(countSig)
或 useState
的值)丟進 useComputed
計算,computed
看不到任何 signal
,只會算一次,之後不會隨 signal
變動。useMemo
:它是渲染期的快取,依賴 React 的值最合適。錯誤:用 React 快照當依賴 → computed
不會更新
const count = useSignalValue(countSig);
const doubled = useComputed(() => count * 2); // 只算一次,不會再變
正確:讓 computed
讀取 signal
const count = useSignalValue(countSig);
const doubled = useComputed(() => countSig.get() * 2); // 會隨 signal 變動
另一種:純 React 衍生值 → 用 useMemo
const count = useSignalValue(countSig);
const doubled = React.useMemo(() => count * 2, [count]); // 不參與 reactive graph
重點:startTransition
只影響 React 的 setState
優先級,不會延後 signals 的寫入。
→ 如果部分 UI 直接讀信號(useSignalValue
),部分 UI 走 setState
+ Transition,你可能看到不同區塊不同步。
所有 UI 都用 useSignalValue
讀取 signals。需要「過渡」的場景用 useDeferredValue
為 顯示加上延遲,而不是延後資料寫入。
function SearchBox() {
const q = useSignalValue(querySig); // 外部真實值
const deferredQ = useDeferredValue(q); // 用於顯示/昂貴結果
return <>
<input value={q} onChange={e => querySig.set(e.target.value)} />
<ExpensiveList query={deferredQ} />
</>;
}
A. 利用 React state 儲存,確認提交時才把值寫回全域 signal。需要過渡時,提交那一刻的 React setState
可以包在 startTransition
function Editor() {
const committed = useSignalValue(titleSig);
const [draft, setDraft] = useState(committed);
useEffect(() => setDraft(committed), [committed]);
const save = () => {
startTransition(() => {
titleSig.set(draft); // 注意:這裡是 signal.set,transition 不會影響它的優先級
// 若還有 React 的 setState,就會被降優先
});
};
return (
<>
<input value={draft} onChange={e => setDraft(e.target.value)} />
<button onClick={save}>Save</button>
</>
);
}
什麼時候用:你想把「打字中的 UI」和「提交後的大量 React 重繪」分開處理,利用 startTransition
降優先級 (只對 React 的 setState
有效)。
B. 利用元件自己的 signal 儲存,只有點「儲存」時才一次寫回全域 signal。因為打字階段不碰全域 signal,不會驅動下游龐大樹,通常也就不需要 startTransition
。
function Editor() {
const committed = useSignalValue(titleSig);
const [draft, setDraft] = useSignalState(committed); // 本地端 signal
useEffect(() => setDraft(committed), [committed]);
const save = () => {
titleSig.set(draft); // 只有這一刻觸發全域訂閱者
};
return (
<>
<input value={draft} onChange={e => setDraft(e.target.value)} />
<button onClick={save}>Save</button>
</>
);
}
computed
/ effect
不會被波及。useDeferredValue
做視覺過渡,而不是指望 startTransition
影響 signal.set()
(做不到)。C. 如果你就是要每鍵即寫 signal,但又想讓重 UI 區塊晚一點更新,對 UI 用 useDeferredValue
。
function Search() {
const q = useSignalValue(querySig);
const deferredQ = useDeferredValue(q); // UI 過渡用
return (
<>
<input value={q} onChange={e => querySig.set(e.target.value)} />
<ExpensiveList query={deferredQ} />
</>
);
}
資料仍即時,過渡交給 React 的 顯示延遲,不是資料寫入延後。
useSignalState
就不需要 startTransition
了嗎?」
useSignalState
透過 useMemo
處裡承接值,不會牽動全域 signal;但如果提交時還伴隨大量 React setState
,你仍可把那些 setState
包在 startTransition
中。只是 signal.set()
的優先級不會被 transition 影響。signal.set()
放進 startTransition
,就能降優先了吧?」
useSyncExternalStore
的機制觸發訂閱元件重渲染,與 startTransition
無關。事實:我們的 signals 不會自動與 React Suspense 整合;Suspense 只認得「render 階段 throw Promise」的資料源。
策略:維持 signals 作為 Source of Truth,在 UI 層用兩種方式處理「未就緒」:
// data layer
const userId = signal(1);
const user = signal<{status:"idle"|"loading"|"ok"|"error"; data?:User; err?:any}>({status:"idle"});
createEffect(() => {
const id = userId.get();
user.set({ status: "loading" });
fetch(`/api/user/${id}`)
.then(r => r.json()).then(d => user.set({ status:"ok", data:d }))
.catch(e => user.set({ status:"error", err:e }));
});
// UI
function UserPanel() {
const u = useSignalValue(user);
if (u.status === "loading") return <Spinner/>;
if (u.status === "error") return <ErrorView err={u.err}/>;
return <Profile data={u.data!}/>;
}
// util/resource.ts
export function toResource<T>(src: { peek(): {status:string; data?:T; err?:any} }) {
let thrown: any = null;
return {
read(): T {
const s = src.peek();
if (s.status === "ok") return s.data!;
if (s.status === "error") throw s.err;
if (!thrown) thrown = new Promise(() => {}); // 請改成真 pending promise
throw thrown;
}
};
}
function UserPanel() {
const resource = React.useMemo(() => toResource(user), []);
// 讀取時若未就緒會 throw,交給 Suspense 邏輯
const data = resource.read();
return <Profile data={data}/>;
}
<React.Suspense fallback={<Spinner/>}>
<UserPanel/>
</React.Suspense>
到這裡,你已經能在 React 18 中穩定、tear-free、低重繪地使用你的 signals。
下一篇,我們來看看哪寫不好的使用範例與修正做法。