Goal of This Article
In this article, we will focus on three practical issues when integrating signals with React:
- Understanding why tearing happens and how to guarantee tear-free subscriptions
- Avoiding stale subscriptions and leftover computed nodes when components remount due to
keychanges - Handling consistency and timing coordination in
TransitionandSuspensescenarios
Data Flow and Responsibility Boundaries
The diagram below summarizes how data flows when signals are used inside a React environment.
We can divide responsibilities clearly:
-
Data side effects → our
createEffect(business logic) -
UI / DOM side effects → React’s
useLayoutEffect/useEffect -
Reading values → always through
useSignalValue(useSyncExternalStore + peek()for tear-free behavior)
Tearing: Cause and Solution
We discussed tearing earlier, but let’s briefly recap.
Under React Concurrent Mode, React may read snapshots multiple times between render and commit.
If your snapshot is not controlled by React (for example, directly calling someSignal.get()), the UI may render with a snapshot that no longer matches the DOM after commit.
React’s Snapshot mechanism is a compromise to coordinate state updates and UI rendering.
It is not part of JavaScript itself, which is why some React developers develop incorrect mental models about JavaScript behavior.
Earlier chapters reviewed some JavaScript fundamentals to avoid this confusion. If you joined the series midway, it may be helpful to revisit those sections.
Two Common Incorrect Patterns
1. Reading .get() directly in a component
function Bad() {
const v = someSignal.get(); // snapshot not controlled by React
return <div>{v}</div>;
}
2. Manual subscription using useState + useEffect
function Bad() {
const [v, setV] = useState(someSignal.peek());
useEffect(() => {
const stop = createEffect(() => {
setV(someSignal.peek());
}); // snapshot not re-read before commit
return () => stop();
}, []);
return <div>{v}</div>;
}
Correct Approach: useSyncExternalStore
Always use useSyncExternalStore through our useSignalValue hook.
function Good() {
const v = useSignalValue(someSignal); // tear-free
return <div>{v}</div>;
}
Avoiding Unnecessary Re-renders: useSignalSelector
const user = signal({ id: 1, name: "Ada", age: 37 });
// Only re-render when name changes
function Name() {
const name = useSignalSelector(user, u => u.name);
return <h2>{name}</h2>;
}
Guidelines
- Never call
.get()during render - Always use
useSignalValueoruseSignalSelector - Do not implement subscriptions manually
-
useSyncExternalStoreautomatically re-reads the snapshot before commit and guarantees tear-free behavior
Remounting with key: Avoiding Stale Subscriptions and Memory Leaks
The Situation
When list keys change or routing switches occur, React will:
- Unmount the old tree
- Mount a new one
The Risk
If your derived values (computed / effect) have long lifetimes and live in module scope, their graph connections may still exist.
Problematic Example
export const expensive = computed(() => a.get() + b.get());
Even if some pages no longer need this value, upstream signals a and b may still retain subscriptions.
Solution
1. Bind to component lifecycle using useComputed
function Page() {
const sum = useComputed(() => aSig.get() + bSig.get());
const v = useSignalValue(sum);
return <div>{v}</div>;
}
When the component unmounts, useComputed will automatically dispose the node.
2. Containerize with a 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>;
}
Practical Rule
Derived values under UI nodes → use useComputed
Global derived values → manage them with a Provider
Important Note About computed
computed is a node in the reactive graph (Observer + Trackable).
Dependencies are tracked only when signal.get() is called inside its callback.
If you pass React snapshots into useComputed, the computed node will not track any signals.
Incorrect Example
const count = useSignalValue(countSig);
const doubled = useComputed(() => count * 2);
// computed runs once and never updates
Correct Example
const count = useSignalValue(countSig);
const doubled = useComputed(() => countSig.get() * 2);
Alternative: Pure React derivation
const count = useSignalValue(countSig);
const doubled = React.useMemo(() => count * 2, [count]);
// not part of the reactive graph
Transitions: Consistency and Timing Coordination
Important point:
startTransition only affects React's setState priority.
It does not delay signal writes.
If some UI reads signals (useSignalValue) while other parts use setState + Transition, you may observe inconsistent UI timing.
Two Safe Strategies
Strategy 1: Always read external snapshots
All UI reads signals through useSignalValue.
For transition-like behavior, use useDeferredValue for display delay, rather than delaying data writes.
function SearchBox() {
const q = useSignalValue(querySig);
const deferredQ = useDeferredValue(q);
return (
<>
<input
value={q}
onChange={e => querySig.set(e.target.value)}
/>
<ExpensiveList query={deferredQ} />
</>
);
}
Strategy 2: Keep transitional values inside React state
A. React state as draft buffer
function Editor() {
const committed = useSignalValue(titleSig);
const [draft, setDraft] = useState(committed);
useEffect(() => setDraft(committed), [committed]);
const save = () => {
startTransition(() => {
titleSig.set(draft);
});
};
return (
<>
<input value={draft} onChange={e => setDraft(e.target.value)} />
<button onClick={save}>Save</button>
</>
);
}
Use this when you want to separate typing UI updates from heavy re-renders after commit.
B. Local signal state
function Editor() {
const committed = useSignalValue(titleSig);
const [draft, setDraft] = useSignalState(committed);
useEffect(() => setDraft(committed), [committed]);
const save = () => {
titleSig.set(draft);
};
return (
<>
<input value={draft} onChange={e => setDraft(e.target.value)} />
<button onClick={save}>Save</button>
</>
);
}
During editing, only the local signal updates, so downstream computations are unaffected.
C. Immediate writes but delayed UI
If you must update the signal on every keystroke, use useDeferredValue for expensive UI sections.
function Search() {
const q = useSignalValue(querySig);
const deferredQ = useDeferredValue(q);
return (
<>
<input
value={q}
onChange={e => querySig.set(e.target.value)}
/>
<ExpensiveList query={deferredQ} />
</>
);
}
The data stays real-time, while the UI update is deferred.
Common Misunderstandings
"If I use useSignalState, I no longer need startTransition?"
Usually yes.
BecauseuseSignalStatestores values locally and does not trigger global signal updates.
However, if committing still triggers heavy React state updates, those setState calls can still be wrapped in startTransition.
"If I wrap signal.set() inside startTransition, will it lower priority?"
No.
Transitions do not affect external stores.
Updates from external stores trigger re-renders through useSyncExternalStore, independent of startTransition.
Suspense: Consistency When Data Is Not Ready
Reality: signals do not automatically integrate with React Suspense.
Suspense only recognizes data sources that throw a Promise during render.
Strategy A: State-driven approach (simpler)
Data layer
const userId = signal(1);
const user = signal({
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} />;
}
Strategy B: Convert into a Suspense resource
export function toResource(src) {
let thrown = null;
return {
read() {
const s = src.peek();
if (s.status === "ok") return s.data;
if (s.status === "error") throw s.err;
if (!thrown) thrown = new Promise(() => {});
throw thrown;
}
};
}
function UserPanel() {
const resource = React.useMemo(() => toResource(user), []);
const data = resource.read();
return <Profile data={data} />;
}
<React.Suspense fallback={<Spinner />}>
<UserPanel />
</React.Suspense>
When to choose which?
If your project does not heavily rely on Suspense → Strategy A is simpler
If you already have Suspense infrastructure → Strategy B works with an adapter
Just remember to provide a real pending Promise.
Conclusion
At this point, you can use your signals in React 18+ with:
- Stable tear-free subscriptions
- Minimal unnecessary re-renders
- Clear responsibility boundaries between React and signals
In the next article, we will look at common anti-patterns and how to fix them.

Top comments (0)