Handling React state with Signals

Oct 20, 2024
react
signals

Discover how Signals can streamline React state management, reducing unnecessary re-renders and boosting application performance.

A crosswalk push button to wait for cross signal

As we mentioned back in handling React state with Observables , React’s component-based architecture relies on state to manage dynamic data within an application whicn can lead to performance issues, especially in larger applications, when the state location is too up in React’s component tree.

State management recall

State is often managed at the top of the component tree (e.g., in a parent component or context). This approach allows multiple child components to access and modify the same data, but when state changes in a parent component, all child components re-render by default. This happens even if the specific state change doesn’t affect all children.

The newly proposed TC39 Signals proposal helps by introducing a state management layer that exists independently of React’s render cycle.

An example App

Using React

Let’s use as an example this application that:

  1. Keeps its state in the top component: a count that increments every 2 seconds and wraps after 2
  2. App’s children are wrapped by a “Theme” component that will change its border color everytime it is re-rendered
  3. A child component that displays the state count
App.tsx
2 collapsed lines
import { useEffect, useState } from "react";
import style from "./App.module.css";
export const App = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const handle = setInterval(() => {
setCount((count + 1) % 3);
}, 2000);
return () => clearInterval(handle);
});
return (
<div className={style.App} role="main">
<Theme>
<Child count={count} />
</Theme>
</div>
);
};
const Theme = ({ children }: React.PropsWithChildren) => {
const hue = Math.floor(Math.random() * 361);
return (
<div className={style.Theme} style={{ borderColor: `hsl(${hue}, 50%, 50%)` }}>
{children}
</div>
);
};
const Child = ({ count }: { count: number }) => {
return <article className={style.AppArticle}>{count}</article>;
};
The Theme component changing its border color

Here’s what happens every 2 seconds:

  1. App updates its count state.
  2. App re-renders, which causes Theme to re-render.
  3. Theme generates a new random hue value.
  4. Child re-renders with the new count value.

The problem is particularly evident with the Theme component:

  • It re-renders unnecessarily every time count changes.
  • Each re-render generates a new random hue, causing the border color to change frequently.
  • This change is unrelated to the count update and not intentional.

In a larger application, this pattern of unnecessary re-renders can lead to performance issues, especially if Theme or similar intermediate components perform more complex operations or have their own state.

Using Signals

Let’s see how it is implemented with signals:

3 collapsed lines
import { useEffect } from "react";
import style from "./App.module.css";
import { Signal, useSignal } from "./signal";
export const App = () => {
const counts = new Signal.Signal(0);
useEffect(() => {
const handle = setInterval(() => counts.set((counts.get() + 1) % 3), 2000);
return () => clearInterval(handle);
});
return (
<div className={style.App} role="main">
<Theme>
<Child counts={counts} />
</Theme>
</div>
);
};
const Theme = ({ children }: React.PropsWithChildren) => {
const hue = Math.floor(Math.random() * 361);
return (
<div className={style.Theme} style={{ borderColor: `hsl(${hue}, 50%, 50%)` }}>
{children}
</div>
);
};
const Child = ({ counts }: { counts: Signal<number> }) => {
const count = useSignal(counts);
return <article className={style.AppArticle}>{count}</article>;
};
The Theme component with its border color fixed

As with Observables , the Theme component is not being re-rendered anymore.

What is a Signal

Similar to Observables, Signals are reactive primitives designed for managing state in a simple and efficient manner, well-suited for use in UI frameworks.

Key characteristics of Signals are:

  • Pull-based model: Signals operate on a pull-based data flow model. This means that the current value of a Signal is only computed when it’s accessed.
  • Automatic dependency tracking: When a computation (like a component render) accesses a Signal, it automatically becomes a subscriber to that Signal.
  • Fine-grained reactivity: Only the specific computations that depend on a changed Signal are re-executed, leading to very efficient updates.
  • Synchronous updates: When a Signal’s value changes, dependent computations are updated immediately and synchronously.

Signal

While an Observable represents a stream of values, a Signal acts more similar to a box holding a value, and you can ask the box for the value it currently holds:

interface Signal<T> {
get(): T;
set(newValue: T): void
}

Signal reactivity

Up until this point all we can do with the Signal we have defined is polling its value. We are missing reactivity. To gain that back we need to introduce the effect function:

type Dispose = () => void;
// Creates a reactive effect that will call the callback function
// whenever any signal used by the callback function changes (this is the
// automatic dependency tracking we have mentioned)
declare function effect(callback: () => void) => Dispose;

Let’s put together an example:

const count = new Signal(0);
effect(() => console.log(count.get()));
count.set(1);
// *Console output*: 1

Poor man’s implementation

Let’s show now how to implement a bare minimum Signal, effect, and useSignal.

signal.ts
2 collapsed lines
import { useEffect, useState } from "react";
export interface Signal<T> {
get(): T;
set(newValue: T): void;
}
export type Dispose = () => void;
export type Consumer = () => void;
export function effect(consumer: Consumer): Dispose {
7 collapsed lines
const previousConsumer = graph.setActiveConsumer(consumer);
consumer();
graph.setActiveConsumer(previousConsumer);
return () => {
graph.dispose(consumer);
};
}
// Signal namespace to avoid types collision between the Signal interface
// and the Signal class.
export namespace Signal {
export class Signal<T> implements Signal<T> {
32 collapsed lines
private value: T;
private consumers: Set<Consumer>;
constructor(initialValue: T) {
this.value = initialValue;
this.consumers = new Set();
}
get() {
// Test if there is an active consumer interested in tracking
// our state updates. We also check if what we want is to add
// it or remove it (dispose) from our consumers list.
const currentConsumer = graph.getActiveConsumer();
if (currentConsumer) {
if (graph.shouldDispose()) {
this.consumers.delete(currentConsumer);
} else {
this.consumers.add(currentConsumer);
}
}
return this.value;
}
set(newValue: T) {
this.value = newValue;
// Notify our consumers about the new update.
// This is the reactivity part of Signals.
for (const consumer of this.consumers) {
consumer();
}
}
}
}
// Helper namespace for handling the state of the signals graph.
namespace graph {
27 collapsed lines
// For automatic dependency tracking.
// Hold the currently active consumer so the signals used internally by it
// will know about implicitly.
let activeConsumer: Consumer | undefined;
let disposeConsumer = false;
export function setActiveConsumer(consumer: Consumer | undefined): Consumer | undefined {
const previousConsumer = activeConsumer;
activeConsumer = consumer;
return previousConsumer;
}
export function getActiveConsumer(): Consumer | undefined {
return activeConsumer;
}
export function dispose(consumer: Consumer): void {
const previousConsumer = setActiveConsumer(consumer);
disposeConsumer = true;
consumer();
disposeConsumer = false;
setActiveConsumer(previousConsumer);
}
export function shouldDispose(): boolean {
return disposeConsumer;
}
}
export function useSignal<T>(signal: Signal<T>): T {
const [value, setValue] = useState(signal.get());
useEffect(() => {
return effect(() => setValue(signal.get()));
}, [signal]);
return value;
}

Signals vs Observables

Compared to Observables, Signals provide a simpler to use API, offering a more intuitive approach to reactive programming that aligns closely with how developers think about state and its changes. While Observables excel in handling complex asynchronous data flows and event streams, Signals shine in synchronous, UI-centric scenarios, providing fine-grained reactivity with less boilerplate.

Most, if not all, of the benefits of using Signals can be achieved with Observables, albeit with more explicit code and careful implementation. Observables, when properly optimized, can match Signals in performance and efficiency through techniques like lazy evaluation, memoization, and selective updates.

It’s important to note that both patterns have their strengths, and the choice between Signals and Observables often depends on the specific requirements of the application, with Signals generally offering a more straightforward and efficient solution for managing UI state, while Observables remain powerful for handling more complex, often asynchronous, data streams.

Conclusions

The future of Signals looks promising, especially with the potential for native browser implementation. As part of the TC39 proposal process, Signals could become a built-in feature of JavaScript, offering significant advantages. Native browser implementation would provide performance optimizations at the engine level, potentially making Signals even more efficient than current JavaScript implementations.

Furthermore, native Signals could integrate seamlessly with other Web APIs, opening up new possibilities for reactive programming in areas like DOM manipulation, Web Components, and even WebAssembly interop.

While the road to standardization and implementation is long, the potential benefits make Signals an exciting prospect for the future of web development, promising a more intuitive and efficient way to handle reactivity in JavaScript applications.