DEV Community

Cover image for Signals in React(IV): Separating UI Effects and Data Effects with Signals
Luciano0322
Luciano0322

Posted on

Signals in React(IV): Separating UI Effects and Data Effects with Signals

Quick Recap

This article continues from the conclusion of the previous one.

We will look at practical examples to understand how React and signals can complement each other in real applications.

Effect Cleanup Strategies

First, let’s clarify the core concept:

  1. Our effect (createEffect) is responsible for data/business cycles. It depends on signals and releases resources using onCleanup before re-running.

  2. React effects (useEffect / useLayoutEffect) are responsible for UI/DOM cycles. They depend on React snapshots (props/state) and clean up before the next commit.


Practical Examples

Timer Example (using createEffect)

Requirement

Poll data based on an adjustable intervalMs and write the latest time into a heartbeat signal.

Key idea

When intervalMs changes, the old setInterval should be automatically cleared and replaced with a new one.

// data/heartbeat.ts
import { signal } from "../core/signal";
import { createEffect, onCleanup } from "../core/effect";

export const intervalMs = signal(1000);
export const heartbeat = signal<Date | null>(null);

createEffect(() => {
  const ms = intervalMs.get(); // depend on signal
  const id = setInterval(() => {
    heartbeat.set(new Date()); // write back to the data layer
  }, ms);

  onCleanup(() => clearInterval(id)); // release before next re-run
});
Enter fullscreen mode Exit fullscreen mode
Why place this inside createEffect?

This is purely a data-layer task:

  • it depends on signals and writes back to signals.
  • It does not interact with the DOM and does not require React’s commit timing.

UI Blinking Example (using useEffect)

Requirement

Make a cursor blink every 500ms.

Key idea

Since this behavior is tightly coupled to the DOM/rendering, it should be managed by React’s lifecycle.

// ui/Blinker.tsx
import { useState, useEffect } from "react";

export function Blinker({ enabled = true }) {
  const [on, setOn] = useState(false);

  useEffect(() => {
    if (!enabled) return;
    const id = setInterval(() => setOn(v => !v), 500);
    return () => clearInterval(id); // cleanup before next commit
  }, [enabled]);

  return <span className={on ? "caret on" : "caret"}>|</span>;
}
Enter fullscreen mode Exit fullscreen mode
Why place this inside useEffect?

This is purely a UI/visual behavior:
it depends on React state/props, and its cleanup timing should follow React’s commit cycle.

Using Them Together Without Interference

// App.tsx
import { useSignalValue } from "./react-adapter";
import { heartbeat, intervalMs } from "../data/heartbeat";
import { Blinker } from "./ui/Blinker";

export function Dashboard() {
  const lastBeat = useSignalValue(heartbeat);
  const ms = useSignalValue(intervalMs);

  return (
    <section>
      <h3>Last heartbeat: {lastBeat?.toLocaleTimeString() ?? ""}</h3>
      <p>Polling every {ms} ms</p>
      <Blinker enabled /> {/* UI timer managed by React */}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Timer polling (createEffect) exists independently of any component and continues even across page navigation.

UI blinking (React useEffect) is created and cleaned up along with component mount/unmount.

The cleanup timing does not conflict:

  • createEffectonCleanup runs before re-execution
  • React useEffect → cleanup runs before the next commit

Common Mistakes and Fixes

Mistake: Using createEffect to control DOM timers

createEffect(() => {
  const id = setInterval(() => {
    el.classList.toggle("on"); // DOM timing not controlled by React
  }, 500);

  onCleanup(() => clearInterval(id));
});
Enter fullscreen mode Exit fullscreen mode

Fix: Let React manage it with useEffect

(See the Blinker example above)

Mistake: Using React useEffect for data polling and directly using a signal as dependency

// Only runs once on mount, will not rebuild when interval changes
React.useEffect(() => {
  const id = setInterval(fetchData, ms /* passing signal directly */);
  return () => clearInterval(id);
}, []); // empty deps
Enter fullscreen mode Exit fullscreen mode

Fix: Two possible approaches

A) Use the hook we created to obtain a snapshot
const ms = useSignalValue(intervalMs);

React.useEffect(() => {
  const id = setInterval(fetchData, ms);
  return () => clearInterval(id);
}, [ms]);
Enter fullscreen mode Exit fullscreen mode
B) Move it back to the data layer with createEffect

Do not call signal functions directly inside React components.
Keep signals outside the component closure to maintain a clean separation.
When you need to read signals inside React, use hooks to access them.

Quick Concept Summary

Scenario Recommended Location Dependency Source Cleanup Timing
Data polling, cache updates, writing to signals our effect signal.get() onCleanup before rerun
Visual effects, DOM operations, measurements React effect React state/props cleanup before next commit
Batching multiple synchronous set() calls scheduler (built-in microtask) microtask flush
Read current value without reverse dependency useSignalValue / peek() not tracked

Understanding Through Mental Models

Responsibilities of Each Layer

Responsibilities of Each Layer

Cleanup Order (during a single update)

Cleanup Order


FAQ

Q: Why does getSnapshot use peek() instead of get()?

A:
To avoid adding the React component itself to the reactive graph’s subs.
peek() will still perform lazy recomputation when the signal is stale, so the snapshot remains up to date.

Q: Can I call createEffect inside the render phase (inside a function component)?

A:
It’s best not to.
The adapter should create and clean up subscriptions through subscribeReadable within the useSyncExternalStore lifecycle.

Q: Do I need to manually batch multiple synchronous set() calls?

A:
No. Our scheduler already merges updates within the same tick into a single microtask.
Cross-await batching will be discussed in the advanced section (transaction(async)).


Conclusion

With the examples above, it should be clearer how signals can complement React.

From a usage perspective, the experience may feel similar to libraries like Jotai or Zustand, but signals provide finer-grained control, are not tied to a specific framework, and therefore offer higher compatibility across environments.

In the next article, we will cover practical patterns and checklists for handling:

  • key-based remounting
  • stale closures
  • consistency of values read during Transitions

With concrete patterns you can directly apply.

Top comments (0)