DEV Community

Cover image for Signals in React (I): Without Breaking the Render Model
Luciano0322
Luciano0322

Posted on

Signals in React (I): Without Breaking the Render Model

Why Does the Framework Affect How You Use Signals?

The short answer is lifecycle.

On social platforms, you’ll often see a group of React developers pretending lifecycle doesn’t exist while selling courses. That honestly worries me a bit—but once you go deep enough, you’ll realize this is mostly marketing rhetoric.

After implementing our signal system step by step in previous articles, you should already have a solid intuition for what lifecycle actually means. In this article, we’ll clarify three key points:

1. Two-phase mental model
React has Render and Commit.
Render can run multiple times and be interrupted; only Commit mutates the DOM.

2. Timing alignment
Our signals merge side effects in microtasks via a scheduler.
React uses event batching and Concurrent rendering.

3. Forbidden zones and correct placement
What must not happen during React’s render phase (e.g. createEffect, signal.set)—and where these belong instead.


TL;DR

  • In React, render must be pure (the Function Component contract). Do not perform side effects or write to signals during render.
  • Side-effect responsibility split:
    • UI-related (DOM measurement, animations) → useLayoutEffect / useEffect
    • Data-flow related (business logic triggered by signals, requests) → createEffect (managed via a React adapter)
  • Subscriptions must go through useSyncExternalStore (covered next article) to avoid tearing.

React vs Signals: Timing Differences

  • React render may run many times (Concurrent mode, StrictMode). Commit runs only once.
  • Our scheduler merges effects at the microtask boundary.
  • Therefore: never create or trigger createEffect during render. Let hooks manage lifecycle instead.

StrictMode and Concurrent Traps

StrictMode (development only)

React will:

create → immediately clean up → create again

This is intentional—to detect side effects during render.

Concurrent Rendering

  • Render can be interrupted and restarted.
  • Reading external mutable state during render can cause tearing.

Official recommendation:
Use useSyncExternalStore to provide snapshots + subscriptions.
React can re-read the snapshot before commit, preventing tearing.


What Not to Do

Creating createEffect during render

function Bad() {
  // Creating external effects during render breaks purity and predictability
  createEffect(() => {
    console.log("value", someSignal.get());
  });
  return <>{/* ...your UI */}</>;
}
Enter fullscreen mode Exit fullscreen mode

Writing to signals during render

function Bad() {
  const v = someSignal.get();
  if (v < 0) someSignal.set(0); // Writing during render → infinite re-renders
  return <>{/* ...your UI */}</>;
}
Enter fullscreen mode Exit fullscreen mode

Capturing get() values in long-lived closures

function Bad() {
  const v = someSignal.get();  // snapshot at render time
  const onClick = () => console.log(v); // always logs stale value
  return <button onClick={onClick}>log</button>;
}
Enter fullscreen mode Exit fullscreen mode

Correct Patterns

Subscriptions

  • Wrap signals with useSyncExternalStoreuseSignalValue(src)
  • Use peek() for snapshots:
    • No React → signal dependency
    • Lazy recompute when stale
  • Inside subscribe, use createEffect to track changes
  • Clean up effects on unmount

Writes

  • Perform writes in event handlers or React effects
  • Never during render

DOM Side Effects

  • Still go through useLayoutEffect / useEffect
  • Pass signal values into React
  • Let React control DOM timing

We won’t implement this yet—this section is to establish the mental model first.


React Batching vs Our batch

React batches setState calls inside event handlers.

Our batch:

  • Only affects signal effect scheduling
  • Does not interfere with React’s commit phase

Example

batch(() => {
  a.set(10);
  b.set(20);
  a.set(30);
});

// Our effects rerun once in a microtask
// React setState (if any) still batches render once per event
Enter fullscreen mode Exit fullscreen mode

Side-Effect Responsibility Table

Task Where to Put It Why
DOM reads/writes, measurement, animation useLayoutEffect / useEffect Controlled by React’s Commit phase
Business effects triggered by signals createEffect (via hooks) Scheduler merges reruns
Multiple updates, single rerun batch / transaction Affects signals only
React subscribing to external state useSyncExternalStore Prevents tearing, supports Concurrent
Immediate effect flush (tests/demos) flushSync() (ours) Clears scheduler immediately

Incorrect vs Correct

❌ Wrong: Subscribing + updating React state manually

function Bad() {
  const [state, setState] = useState();
  const v = someSignal.get();

  useEffect(() => {
    // Prone to tearing and duplicate updates
    const stop = createEffect(() => {
      someSignal.get();
      setState(Date.now());
    });
    return () => stop();
  }, []);

  return <div>{v}</div>; // v is not a React-managed snapshot
}
Enter fullscreen mode Exit fullscreen mode

✅ Correct: Use useSyncExternalStore

function Good() {
  // Implemented in the next article
  const v = useSignalValue(someSignal);
  return <div>{v}</div>; // React-controlled synchronous snapshot
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

With these concepts in place, you should now have a more accurate understanding of React’s rendering and update mechanics.

Our goal is not to replace React’s state system.

Instead, we treat signals as external data sources—integrated in a way that:

  • avoids tearing in Concurrent mode
  • minimizes unnecessary reruns
  • keeps behavior predictable

In the next article, we’ll implement the missing pieces and show how to correctly integrate our signal system into React.

Top comments (0)