Signals in React (VI): Stale Closures, Subscription Traps, and Reactive Graph Pitfalls

javascript dev.to

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
// ✅ stable instances
function Good() {
  const [local, setLocal] = useSignalState(0);
  const sum = useComputed(() => local + 1);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// ✅ tracked via signal
const doubled2 = useComputed(() => countSig.get() * 2);
Enter fullscreen mode Exit fullscreen mode
// ✅ render cache only
const doubled3 = React.useMemo(() => count * 2, [count]);
Enter fullscreen mode Exit fullscreen mode

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();
}, []);
Enter fullscreen mode Exit fullscreen mode
// ✅ tear-free
const v = useSignalValue(src);
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// ✅ buffer with React state, then commit to signal
const [draft, setDraft] = useState(useSignalValue(titleSig));
startTransition(() => titleSig.set(draft));
Enter fullscreen mode Exit fullscreen mode

Key Concepts Recap

Reading values

Use:

useSignalValue
useSignalSelector
Enter fullscreen mode Exit fullscreen mode

Building dependencies

Inside a computed callback, dependencies must be accessed via:

signal.get()
Enter fullscreen mode Exit fullscreen mode

Instance lifecycle

Stabilize instances inside components using:

useSignalState
useComputed
Enter fullscreen mode Exit fullscreen mode

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

  • signal
  • effect
  • computed
  • push dirty-marking + pull recomputation
  • minimal scheduler with microtask batching

React Integration

  • useSignalValue
  • useComputed
  • useSignalSelector
  • 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 */
}
Enter fullscreen mode Exit fullscreen mode

Important notes:

  • The callback of useComputedRef must read signal.get(), not ref.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 associated createEffect or computed.
Read Full Tutorial open_in_new
arrow_back Back to Tutorials