DEV Community

Cover image for Signals in React (III): Lifecycle Never Disappeared
Luciano0322
Luciano0322

Posted on

Signals in React (III): Lifecycle Never Disappeared

The Lifecycle Never Went Away

From the beginning of this series up to the current implementation, everything has revolved around the lifecycle of the data layer:

How data is read, invalidated, recomputed, and when side effects are triggered.

This does not conflict with the framework’s lifecycle. In fact, React never removed lifecycle — it restructured it into two distinct phases:

  • Render: Purely computes UI. It may run multiple times, be interrupted, or discarded. Ideally, no side effects should occur here.
  • Commit: Applies changes to the DOM in a single, synchronous step. useLayoutEffect / useEffect setup and cleanup run here. This is the only legitimate place for UI side effects.

If you’re not familiar with useLayoutEffect and useEffect, review how React manages mount/unmount timing via these hooks. That understanding is foundational.


React emphasizes that in typical usage, UI dependencies on state are not explicitly declared. When state updates, React re-runs the component render and uses VDOM diffing to determine minimal DOM changes. Whether child subtrees update depends on bailout and memoization strategies.

Signals take a different route: an explicit dependency graph.

The system knows who depends on whom, allowing precise update propagation. A scheduler (e.g., microtask batching) controls when side effects run.

The two approaches may look different, but both rigorously manage lifecycle boundaries:

  • React protects side-effect timing via the Render/Commit boundary.
  • Signals manage data lifecycle through invalidation, recomputation, and subscription boundaries.

The lifecycle never disappeared — it simply exists at different abstraction layers.


Goal of This Article

We clearly separate responsibilities:

  • UI-related side effects (DOM measurement, manipulation, animation) → React (useLayoutEffect / useEffect)
  • Data-flow side effects (business logic triggered by signal/computed changes) → signals (createEffect, lifecycle managed by our adapter)
  • Render must remain pure — no signal.set() or external effect creation during render.

Timing Overview: Who Runs First? Who Cleans First?

Signal in react timeline
React effect cleanup (useEffect / useLayoutEffect) runs before the next commit.

Our signal effect cleanup (onCleanup) runs before re-execution within the same microtask batch.

They operate independently and do not conflict.


Responsibility Separation

Concern Where It Belongs Explanation
Read/write DOM, measurement, animation useLayoutEffect / useEffect Runs after commit, timing is predictable
Trigger business logic (requests, logging, cross-layer events) createEffect (via adapter subscription) Scheduler batches and re-runs in microtasks
Merge multiple synchronous set() calls Scheduler (microtask dedupe) Effects re-run only once per batch
Read current snapshot useSignalValue / useComputed useSyncExternalStore + peek() prevents tearing in Concurrent mode

Correct Usage Patterns

1. Writing During Render vs Writing in Event / Effect

❌ Incorrect (side effect during render)

function Bad() {
  const v = useSignalValue(mySig);
  if (v < 0) mySig.set(0); // Writing during render → infinite re-render / StrictMode issues
  return null;
}
Enter fullscreen mode Exit fullscreen mode

✅ Correct (write in React effect)

function Good() {
  const v = useSignalValue(mySig);

  React.useEffect(() => {
    if (v < 0) mySig.set(0);
  }, [v]); // Safe: runs after commit

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Or inside an event handler:

<button onClick={() => mySig.set(x => Math.max(0, x))}>
  Clamp
</button>
Enter fullscreen mode Exit fullscreen mode

This mirrors a common React rule: never mutate state during function component render.

2. DOM Manipulation in Our Effect vs React Effect

❌ Incorrect (DOM mutation inside our effect)

createEffect(() => {
  const h = panelHeight.get();
  panelEl.style.height = h + "px"; // May conflict with React commit
});
Enter fullscreen mode Exit fullscreen mode

✅ Correct (pass value to React; mutate DOM in layout effect)

function Panel({ el }: { el: HTMLElement }) {
  const h = useSignalValue(panelHeightSignal);

  React.useLayoutEffect(() => {
    el.style.height = h + "px"; // Safe: runs after commit
  }, [el, h]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

The DOM lifecycle belongs to React.
Our effects are better suited for data-level side effects.

3. useEffect Subscription vs useSyncExternalStore

❌ Incorrect (manual subscription via useEffect → tearing risk)

function BadSubscribe() {
  const [v, setV] = React.useState(mySig.peek());

  React.useEffect(() => {
    const stop = createEffect(() => {
      setV(mySig.peek()); // May cause tearing
    });
    return () => stop();
  }, []);

  return <div>{v}</div>;
}
Enter fullscreen mode Exit fullscreen mode

✅ Correct (use adapter hook built on useSyncExternalStore)

function GoodSubscribe() {
  const v = useSignalValue(mySig); // useSyncExternalStore + peek()
  return <div>{v}</div>;
}
Enter fullscreen mode Exit fullscreen mode

useSyncExternalStore guarantees snapshot consistency under Concurrent rendering, preventing tearing.

(For older React versions, a third-party shim can be considered.)

4. Computed Depending on React Snapshot vs Signal

❌ Incorrect (computed depends on React value)

const count = useSignalValue(countSig);
const doubled = useComputed(() => count * 2); // Only runs once
Enter fullscreen mode Exit fullscreen mode

This does not establish a dependency in the reactive graph.

✅ Correct (computed reads signal directly)

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

Or use pure React derivation when no reactive node is needed:

const count = useSignalValue(countSig);
const doubled = useMemo(() => count * 2, [count]);
Enter fullscreen mode Exit fullscreen mode

A computed must call .get() inside its execution to be tracked.


Lessons from These Mistakes

From the examples above, we can extract clear principles:

  • Render must remain pure — no createEffect or signal.set() during render.
  • UI side effects → useLayoutEffect / useEffect.
  • Data side effects → createEffect.
  • Always subscribe via useSignalValue (useSyncExternalStore).
  • computed must read .get(), not React snapshots.

Conclusion

React Effects include UI rendering updates.
If we want our signal system to work correctly within React, integration must respect React’s lifecycle hooks.

In short:

  • APIs from our mechanism should accept signals directly.
  • Values wrapped via hooks take on React’s shape, and therefore must interact through React hooks.

Only then will dependency tracking behave correctly.

In the next article, we’ll look at concrete examples demonstrating how both systems can complement each other.

Top comments (0)