DEV Community

Cover image for Signals in React (II): Tear-Free Subscriptions
Luciano0322
Luciano0322

Posted on

Signals in React (II): Tear-Free Subscriptions

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() from useSyncExternalStore.

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

useSignalValue

export function useSignalValue<T>(src: Readable<T>) {
  const getSnapshot = () => src.peek();

  return useSyncExternalStore(
    (notify) => subscribeReadable(src, notify),
    getSnapshot, // client
    getSnapshot  // server (SSR)
  );
}
Enter fullscreen mode Exit fullscreen mode

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

useSignalState

export function useSignalState<T>(initial: T) {
  const sig = useMemo(() => signal<T>(initial), []);
  const value = useSignalValue(sig);
  return [value, sig.set] as const;
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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)
  • getSnapshot must be pure (no side effects)
  • ✅ Do not create external effects during render
  • ✅ Lifecycle must be managed by useSyncExternalStore

subscribe dataflow


Common Pitfalls

❌ Subscribing via useEffect

useEffect(() => {
  const stop = createEffect(() => { 
    someSignal.get();
    setState(someSignal.peek());
  });
  return () => stop();
}, []);
Enter fullscreen mode Exit fullscreen mode

This can cause synchronization issues.

✅ Correct Solution
const value = useSignalValue(mySignal);
Enter fullscreen mode Exit fullscreen mode

React will re-check the snapshot before commit, preventing tearing.

❌ Capturing get() in a Closure

const v = someSignal.get();
const onClick = () => console.log(v);
Enter fullscreen mode Exit fullscreen mode

This creates a stale closure.

✅ Correct Solution
const latest = useSignalValue(someSignal);
const onClick = () => console.log(latest);
Enter fullscreen mode Exit fullscreen mode

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