DEV Community

Cover image for Signals in React (V): Avoiding Tearing, Remount Leaks, and Transition Pitfalls
Luciano0322
Luciano0322

Posted on

Signals in React (V): Avoiding Tearing, Remount Leaks, and Transition Pitfalls

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 key changes
  • Handling consistency and timing coordination in Transition and Suspense scenarios

Data Flow and Responsibility Boundaries

The diagram below summarizes how data flows when signals are used inside a React environment.

signals diagram

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

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

Correct Approach: useSyncExternalStore

Always use useSyncExternalStore through our useSignalValue hook.

function Good() {
  const v = useSignalValue(someSignal); // tear-free
  return <div>{v}</div>;
}
Enter fullscreen mode Exit fullscreen mode

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

Guidelines

  • Never call .get() during render
  • Always use useSignalValue or useSignalSelector
  • Do not implement subscriptions manually
  • useSyncExternalStore automatically 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:

  1. Unmount the old tree
  2. 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());
Enter fullscreen mode Exit fullscreen mode

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

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

Correct Example

const count = useSignalValue(countSig);
const doubled = useComputed(() => countSig.get() * 2);
Enter fullscreen mode Exit fullscreen mode

Alternative: Pure React derivation

const count = useSignalValue(countSig);

const doubled = React.useMemo(() => count * 2, [count]);
// not part of the reactive graph
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

The data stays real-time, while the UI update is deferred.


Common Misunderstandings

"If I use useSignalState, I no longer need startTransition?"

Usually yes.
Because useSignalState stores 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",
});
Enter fullscreen mode Exit fullscreen mode
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 }));
});
Enter fullscreen mode Exit fullscreen mode

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

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;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode
function UserPanel() {
  const resource = React.useMemo(() => toResource(user), []);

  const data = resource.read();

  return <Profile data={data} />;
}
Enter fullscreen mode Exit fullscreen mode
<React.Suspense fallback={<Spinner />}>
  <UserPanel />
</React.Suspense>
Enter fullscreen mode Exit fullscreen mode

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)