Quick Overview
In the previous article, we discussed:
- Why tearing occurs and how to guarantee tear-free subscriptions
- How to avoid dangling subscriptions or computed nodes when components remount due to
keys - Practical strategies for consistency and timing coordination when using Transition and Suspense
In this article, we will go through several examples to better understand common mistakes and their solutions.
Common Pitfalls
Stale closures: reading outdated values in events or async callbacks
Symptom
Values used inside setTimeout, debounce, or Promise.then are always outdated.
Cause
Event handlers and async callbacks capture the snapshot from the render that created them.
Fix
Read the snapshot again inside the callback using peek(), or inject a getter function to avoid closure persistence.
// ❌ onClick / timeout always uses "v from that render"
const v = useSignalValue(countSig);
const onClick = () => setTimeout(() => console.log(v), 500);
// ✅ read the latest value at callback time (no tracking)
const onClick2 = () => setTimeout(() => console.log(countSig.peek()), 500);
// ✅ or inject a getter
const getCount = () => countSig.peek();
const onClick3 = () => setTimeout(() => console.log(getCount()), 500);
Creating signal / computed instances inside component render
Symptom
Instances are recreated on every render, leading to subscription leaks or performance jitter.
Cause
Component functions execute on every render. Without useMemo or a custom hook, instances are not stable.
Fix
Use useSignalState, useComputed, or useMemo so the instance is created only once.
// ❌ recreated every render → guaranteed leak
function Bad() {
const local = signal(0);
const sum = computed(() => local.get() + 1);
}
// ✅ stable instances
function Good() {
const [local, setLocal] = useSignalState(0);
const sum = useComputed(() => local + 1);
}
Computed depending on React snapshots (won't update)
Symptom
useComputed(() => count * 2) runs only once and never updates.
Cause
computed only tracks signals accessed through .get() inside its callback.
React snapshot values are not part of the reactive graph.
Fix
Read signal.get() inside computed.
If you only need a render-time cache, use useMemo.
// ❌ not tracked
const count = useSignalValue(countSig);
const doubled1 = useComputed(() => count * 2);
// ✅ tracked via signal
const doubled2 = useComputed(() => countSig.get() * 2);
// ✅ render cache only
const doubled3 = React.useMemo(() => count * 2, [count]);
Manual subscriptions (useEffect + setState) causing tearing or excessive renders
Symptom
In Concurrent mode, UI and data become out of sync, or render counts spike.
Cause
If you manually create an effect inside useEffect and then call setState, React cannot re-read the snapshot before commit.
Fix
Always use useSignalValue or useSignalSelector, which internally rely on useSyncExternalStore.
// ❌ prone to tearing
useEffect(() => {
const stop = createEffect(() => setV(src.peek()));
return () => stop();
}, []);
// ✅ tear-free
const v = useSignalValue(src);
Misusing .get() during cleanup
Symptom
During cleanup, new dependency edges are accidentally created, leading to leaks or unexpected behavior.
Cause
Calling .get() inside onCleanup or React cleanup.
Fix
Use the previously captured snapshot or peek() to avoid tracking during cleanup.
createEffect(() => {
const last = someComputed.peek(); // snapshot first
onCleanup(() => {
// ❌ avoid
// someComputed.get();
last; // ✅ use snapshot
someComputed.peek(); // ✅ peek does not track
});
});
Mixing sources leads to inconsistent UI
Symptom
Some UI elements update immediately, while others update later.
Cause
Some values are read from signals (immediate) while others use React state + Transition (deferred).
Fix
Unify the source of truth.
// ✅ read from signal, defer only in the UI
const q = useSignalValue(querySig);
const dq = useDeferredValue(q);
// ✅ buffer with React state, then commit to signal
const [draft, setDraft] = useState(useSignalValue(titleSig));
startTransition(() => titleSig.set(draft));
Key Concepts Recap
Reading values
Use:
useSignalValue
useSignalSelector
Building dependencies
Inside a computed callback, dependencies must be accessed via:
signal.get()
Instance lifecycle
Stabilize instances inside components using:
useSignalState
useComputed
Cleanup
During cleanup, use snapshots or peek(), and avoid .get().
Responsibility separation
- UI / DOM side effects → React effects (
useEffect) - Data side effects → our effects (
createEffect)
Conclusion
With these examples, it should now be easier to grasp the practical characteristics of using Signals and more importantly, how to avoid common pitfalls.
At this point, we have completed a working core system and React integration:
Core
signaleffectcomputed- push dirty-marking + pull recomputation
- minimal scheduler with microtask batching
React Integration
useSignalValueuseComputeduseSignalSelector- built on
useSyncExternalStore - correct effect separation and cleanup timing in Concurrent Mode / Strict Mode
What's Next
Next, we'll approach the same mental model from a different angle and bring it into Vue.
The good news is that the computed in this series is almost equivalent to Vue's computed, so the implementation will feel very natural.
Preview of the Next Article
We will implement two minimal hooks so Vue templates can directly use your state and derived values:
// vue-adapter
export function useSignalRef<T>(src: { get(): T; peek(): T }) {
/* map to Vue ref, cleanup via onUnmounted */
}
export function useComputedRef<T>(fn: () => T, equals = Object.is) {
/* create computed within lifecycle and convert to ref */
}
Important notes:
- The callback of
useComputedRefmust readsignal.get(), notref.value, otherwise it becomes a pure Vue computation and loses your reactive graph. - The bridge only synchronizes values to Vue refs, and does not reconnect Vue's reactivity back into your graph, avoiding circular scheduling dependencies.
- Cleanup uses
onUnmounted, so unmounting releases the associatedcreateEffectorcomputed.