iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0

本篇目標

  • 理解為什麼會 撕裂(tearing),如何保證 tear-free 訂閱
  • keys 重掛 下如何避免殘留訂閱 / 計算節點
  • 在 Transition 與 Suspense 場景下的「一致性」與「時機協調」實務

資料流與責任邊界

我們透過下面圖表,來總結一下目前應用在 React 環境中的資料流,會是怎麼處裡的:
https://ithelp.ithome.com.tw/upload/images/20250822/20129020B5KZZyCVA8.png

  • 資料副作用 → 我們的 createEffect(business)
  • UI / DOM 副作用 → React 的 useLayoutEffect / useEffect
  • 讀取值 → 一律 useSignalValueuseSyncExternalStore + peek(),tear-free)

撕裂(tearing):來源與解法

前面章節已經有講過了,這裡在快速讓大家複習一下:

  • Concurrent 模式下,React 可能在 render 與 commit 之間重複讀快照。
  • 如果你的快照不是 React 管控的(例如直接 someSignal.get()),就可能出現 DOM 顯示與資料快照不同

React 本身的快照機制(Snapshot),是方便他處理狀態更新UI 渲染交互的一種折衷解法,這個機制並不是 JavaScript 的標準,這也導致很多 React 仔會產生對 JavaScript 的錯誤認知,前面的章節也複習過一些基礎的 JavaScript 觀念了,以防中間加入的讀者會搞不清楚,這裡還是提醒一下,也可以快速讀一下這一篇

兩種常見的例子

1. 直接在元件讀 .get()

function Bad() {
  const v = someSignal.get(); // 非 React 管控的快照
  return <div>{v}</div>;
}

2. 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 保證。

keys 重掛(remount):避免殘留與內存洩漏

現象:列表 / router 切換 key 時,React 會 卸載舊樹、重掛新樹
風險:若你的衍生值(computed / effect)長壽命且建在模組域,它的連接邊界可能仍留著。

錯誤範例:模組域 computed 未退訂

export const expensive = computed(() => a.get() + b.get()); // 可能被多處元件讀取
// 某些頁面不再需要它,但 subs 仍存在 → 上游 a/b 仍持有連接

解決方法

1. 綁生命週期:在元件內用 useComputed

function Page() {
  const sum = useComputed(() => aSig.get() + bSig.get());
  const v = useSignalValue(sum);
  return <div>{v}</div>;
} // 卸載時 useComputed 會 dispose

2. 容器化:在 Provider 內集中建立,跟著 Provider 生命週期走

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>;
}
  • UI 節點下的衍生值 → 用 useComputed,讓 React 幫你收尾。
  • 全域衍生值 → 用 Provider 管理。

重要補充

  • computed反應式圖上的節點(Observer + Trackable)。它只有在 callback 裡讀到 signal.get() 才會被「誰更新 → 我要重算」這條邊追蹤到。
  • 如果你把 React 的快照(例如 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

Transition:一致性與時機協調

重點startTransition 只影響 React 的 setState 優先級,不會延後 signals 的寫入
→ 如果部分 UI 直接讀信號(useSignalValue),部分 UI 走 setState + Transition,你可能看到不同區塊不同步。

兩個安全策略作法

1. UI 一律讀外部快照

所有 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} />
  </>;
}

2. 把「過渡中的值」留在 React state

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>
    </>
  );
}
  • 編輯過程只更新本地 signal,下游的 computed / effect 不會被波及。
  • 提交當下觸發一次全域更新;如果這會牽動很大 UI,你可以考慮 UI 端用 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,就能降優先了吧?」
    • 不行。Transition 不作用於外部 store。外部 store 的更新會依 useSyncExternalStore 的機制觸發訂閱元件重渲染,與 startTransition 無關。

Suspense:資料尚未就緒時的一致性

事實:我們的 signals 不會自動與 React Suspense 整合;Suspense 只認得「render 階段 throw Promise」的資料源。
策略:維持 signals 作為 Source of Truth,在 UI 層用兩種方式處理「未就緒」:

A. 狀態驅動(簡潔,無 throw)

// 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!}/>;
}

B. 轉成 Suspense Resource(需要一個小適配器)

// 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>
  • 專案不重度用 Suspense → A 足夠、較直覺。
  • 你已有 Suspense 基礎設施 → 可用 B 適配,但務必提供真實的 pending promise。

結語

到這裡,你已經能在 React 18 中穩定、tear-free、低重繪地使用你的 signals。

下一篇,我們來看看哪寫不好的使用範例與修正做法。


上一篇
React 應用(IV):React 與 signals 怎麼互補
下一篇
React 應用(VI):高頻陷阱與最佳實務(II)
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言