把前面實作過的 signals/computed 以 不撕裂(tear-free) 的方式接進 React 18 Concurrent 模式
關鍵:快照 + 訂閱 必須交給 useSyncExternalStore。
React 的 Render 可被打斷、重做;如果你在 render 期間直接讀外部可變資料(例如 someSignal.get()),接著又在 commit 時期資料變了,就會出現 DOM 與快照不同步 的「tearing」。
useSyncExternalStore 解法getSnapshot(同步快照讀取)subscribe(資料變動時通知 React 重新讀快照)peek():不建立 React→signal 的反向依賴,但在 stale 時仍會 lazy 重算,拿到最新值。createEffect:在 effect 內讀 signal.get() 以自動追蹤,變動時觸發 useSyncExternalStore 的 notify()。setState:避免在訂閱當下(render 期)回呼。import { useEffect, useMemo, useSyncExternalStore, useRef } from "react";
import { createEffect } from "../core/effect.js";
import { computed } from "../core/computed.js";
import { signal } from "../core/signal.js";
type Readable<T> = { get(): T; peek(): T };
function subscribeReadable<T>(src: Readable<T>, notify: () => void) {
  // 用前面章節的反應式 effect 訂閱來源;首次執行不通知,避免 render 期 setState
  let first = true;
  const stop = createEffect(() => {
    src.get(); // 追蹤依賴
    if (first) {
      first = false;
      return;
    }
    notify(); // 後續變化才通知 React 重取快照
  });
  return () => stop(); // useSyncExternalStore 會在卸載或重掛時呼叫
}
export function useSignalValue<T>(src: Readable<T>) {
  const getSnapshot = () => src.peek(); // 不追蹤,但 stale 時會 lazy 重算
  return useSyncExternalStore(
    (notify) => subscribeReadable(src, notify),
    getSnapshot, // client snapshot
    getSnapshot  // server snapshot(SSR)
  );
}
export function useComputed<T>(
  fn: () => T,
  equals: (a: T, b: T) => boolean = Object.is
) {
  // 用 ref 封裝最新的 fn / equals,避免因函數 identity 改變而重建 computed
  const fnRef = useRef(fn);
  fnRef.current = fn;
  const eqRef = useRef(equals);
  eqRef.current = equals;
  // 只建一次 computed;其內部每次取值都使用最新的 fn/equals
  const memo = useMemo(() => {
    const c = computed(
      () => fnRef.current(),
      (a, b) => eqRef.current(a, b)
    );
    // 暖機:讓 peek() 初次就有快照
    c.get();
    return c;
  }, []);
  // 卸載清理
  useEffect(() => () => memo.dispose?.(), [memo]);
  // 用你原來的機制訂閱(首次不 notify 也沒關係,因為我們已經暖機)
  return useSignalValue(memo);
}
// React 風格的 signal 狀態
export function useSignalState<T>(initial: T) {
  const sig = useMemo(() => signal<T>(initial), []);
  const value = useSignalValue(sig);
  return [value, sig.set] as const;
}
export function useSignalSelector<S, T>(
  src: Readable<S>,
  selector: (s: S) => T,
  isEqual: (a: T, b: T) => boolean = Object.is
) {
  const selectorRef = useRef(selector);
  selectorRef.current = selector;
  const eqRef = useRef(isEqual);
  eqRef.current = isEqual;
  // src 變了才重建;其餘都用 ref 讀「最新」的 selector/isEqual
  const memo = useMemo(() => {
    const c = computed(
      () => selectorRef.current(src.get()),
      (a, b) => eqRef.current(a, b)
    );
    // 暖機
    c.get();
    return c;
  }, [src]);
  useEffect(() => () => memo.dispose?.(), [memo]);
  return useSignalValue(memo);
}
import { useSignalState, useComputed } from "./react-adapter";
export function Counter() {
  const [count, setCount] = useSignalState(0);
  const doubled = useComputed(() => count * 2);
  return (
    <div>
      <p>{count} / {doubled}</p>
      <button onClick={() => setCount(v => v + 1)}>+1</button>
    </div>
  );
}
或者是:
import { useMemo } from "react";
import { signal } from "../core/signal.js";
import { useSignalValue } from "./react-adapter";
// 這樣就能作為 bottom-up 的全域狀態管理使用
const countSignal = signal(0);
export function Counter() {
  const count = useSignalValue(countSignal);
  const doubled = useMemo(() => count * 2, [count]);
  return (
    <div>
      <p>{count} / {doubled}</p>
      <button onClick={() => countSignal.set(v => v + 1)}>+1</button>
    </div>
  );
}
import { signal } from "../core/signal";
import { useSignalSelector } from "./react-adapter";
const user = signal({ id: 1, name: "Ada", age: 37 });
export function ProfileName() {
  const name = useSignalSelector(user, u => u.name);
  return <h2>{name}</h2>;
}
set() 整併function Buttons() {
  const [a, setA] = useSignalState(0);
  const [b, setB] = useSignalState(0);
  const sum = useComputed(() => a + b);
  const clickFn = () => {
    setA(v => v + 1);
    setB(v => v + 1);
  };
  
  return (
    <>
      <p>sum = {sum}</p>
      <button onClick={clickFn}>
        +A & +B
      </button>
    </>
  );
}
我們的 scheduler 會把同一輪(同一個 call stack)內的多次 set() 合併到一個 microtask 內執行,因此上例只會重跑一次 effect。
notify()(hook 中 first tag),避免 render 期 setState。stop() 可被呼叫多次(StrictMode 會先訂閱→立刻解除→再訂閱)。getSnapshot = src.peek 沒有副作用,允許 React 在 commit 前多次呼叫。subscribeReadable() 內,由 useSyncExternalStore 管理生命週期。
useEffect 來訂閱// 會造成無法正確同步
useEffect(() => {
  const stop = createEffect(() => { 
    someSignal.get();
    setState(someSignal.peek());
  });
  return () => stop();
}, []);
useSyncExternalStoreconst mySignal = signal(0);
// React 會在 commit 前重取快照,避免撕裂
const value = useSignalValue(mySignal);
get() 值直接放進閉包const v = someSignal.get(); // 這樣會變成取當下的拷貝
const onClick = () => console.log(v); // stale closure
const latest = useSignalValue(someSignal);
const onClick = () => console.log(latest);
你可以簡單理解為 任何需要被 react 驅動更新的狀態值,都要通過 hook 處理,讓他能夠被 react 本身的生命週期追蹤到 。
batch/transaction 的相處之道batch/transaction 只影響我們的 effect 合併重跑,不會更動 React 的 render/commit。transaction(() => { a.set(...); b.set(...); }):
useSignalValue(memo) 讀取會拿到「最新(lazy 重算後)」的值。透過上面的 hook,我們已經可以順利在 React 環境下使用我們自己定義的 Signal 系統來管理狀態了,而且還是細顆粒化的版本。
下一篇,我們來探討理解副作用的整合。