Goal of This Article
Integrate our previously implemented signal / omputed system into React 18 Concurrent Mode in a tear-free way.
The key principle:
Snapshot reading + subscription must be handled by
useSyncExternalStore.
Why Does Tearing Happen?
In React 18, the render phase can be interrupted, restarted, and replayed.
If you read mutable external data directly during render (ex: someSignal.get()) and that data changes before the commit phase finishes, the DOM may reflect a different state than the snapshot React rendered with.
This inconsistency is called tearing:
The rendered output and the underlying snapshot are no longer synchronized.
The useSyncExternalStore Solution
React provides a built-in mechanism specifically designed to prevent tearing:
-
getSnapshot→ synchronous snapshot reader -
subscribe→ notify React when data changes
React will re-read the snapshot right before commit to ensure consistency.
This is the only officially supported way to safely connect external mutable state to Concurrent React.
What We Need for Our Signal System
To integrate properly:
1️⃣ Snapshot Source: peek()
-
peek()does not establish reactive tracking. - If the computed value is stale, it still lazily recomputes.
- It guarantees we always read the latest value.
2️⃣ Subscription Mechanism: createEffect
- Inside the effect, we call
signal.get()to establish dependency tracking. - When dependencies change, we call
notify()fromuseSyncExternalStore.
3️⃣ Avoid setState During Initial Render
The first subscription execution must not trigger notify(),
otherwise we would cause setState during render.
Hook Implementation
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) {
let first = true;
const stop = createEffect(() => {
src.get(); // track dependencies
if (first) {
first = false;
return;
}
notify(); // notify React only after first run
});
return () => stop();
}
useSignalValue
export function useSignalValue<T>(src: Readable<T>) {
const getSnapshot = () => src.peek();
return useSyncExternalStore(
(notify) => subscribeReadable(src, notify),
getSnapshot, // client
getSnapshot // server (SSR)
);
}
useComputed
export function useComputed<T>(
fn: () => T,
equals: (a: T, b: T) => boolean = Object.is
) {
const fnRef = useRef(fn);
fnRef.current = fn;
const eqRef = useRef(equals);
eqRef.current = equals;
const memo = useMemo(() => {
const c = computed(
() => fnRef.current(),
(a, b) => eqRef.current(a, b)
);
c.get(); // warm-up
return c;
}, []);
useEffect(() => () => memo.dispose?.(), [memo]);
return useSignalValue(memo);
}
useSignalState
export function useSignalState<T>(initial: T) {
const sig = useMemo(() => signal<T>(initial), []);
const value = useSignalValue(sig);
return [value, sig.set] as const;
}
useSignalSelector
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;
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);
}
Usage Examples
Counter Example
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>
);
}
Bottom-Up Global State
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>
);
}
Selector Example
const user = signal({ id: 1, name: "Ada", age: 37 });
export function ProfileName() {
const name = useSignalSelector(user, u => u.name);
return <h2>{name}</h2>;
}
Multiple set() Calls Are Batched
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>
</>
);
}
Our scheduler merges multiple set() calls within the same call stack into a single microtask.
So only one effect re-execution occurs.
StrictMode & Concurrent Safety Checklist
- ✅ Do not call
notify()on first subscription - ✅ Disposer must be re-entrant (StrictMode mounts → unmounts → mounts again)
- ✅
getSnapshotmust be pure (no side effects) - ✅ Do not create external effects during render
- ✅ Lifecycle must be managed by
useSyncExternalStore
Common Pitfalls
❌ Subscribing via useEffect
useEffect(() => {
const stop = createEffect(() => {
someSignal.get();
setState(someSignal.peek());
});
return () => stop();
}, []);
This can cause synchronization issues.
✅ Correct Solution
const value = useSignalValue(mySignal);
React will re-check the snapshot before commit, preventing tearing.
❌ Capturing get() in a Closure
const v = someSignal.get();
const onClick = () => console.log(v);
This creates a stale closure.
✅ Correct Solution
const latest = useSignalValue(someSignal);
const onClick = () => console.log(latest);
Mental Model
Any state value that should participate in React-driven updates
must go through a hook so React can track it within its lifecycle.
Interacting with Batch / Transaction
batch and transaction only affect our internal effect scheduling.
They do not interfere with React’s render/commit phases.
Inside an event:
transaction(() => {
a.set(...);
b.set(...);
});
- React batches its own state updates.
- Our effects rerun only once (microtask).
-
useSignalValue()reads the latest lazily recomputed value.
Conclusion
With these hooks, we can now safely use our custom fine-grained Signal system inside React — fully compatible with Concurrent Mode, StrictMode, and SSR.
And we achieve this without sacrificing granular reactivity.
In the next article, we’ll explore how to reason about side-effect integration at a deeper level.

Top comments (0)