Handling React state with Observables

Oct 11, 2024
react
observables

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

A telescope over Paris.

Photo by Ben Krb under the Unsplash license

React’s component-based architecture relies on state to manage dynamic data within an application. When state changes, React re-renders the component and its children to reflect these updates. While this model is generally efficient, it can lead to performance issues, especially in larger applications because of the state location.

State management

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.

This is where alternative state management solutions, like Observables or the newly proposed TC39 Signals proposal , can help, because they change this dynamic by introducing a state management layer that exists independently of React’s render cycle.

When you use Observables to manage state, the data itself lives outside of React components. This means that when the data in an Observable changes, React remains blissfully unaware. It’s as if these changes are invisible to React’s watchful eye. The magic then happens when we connect Observables to React components by subscribing directly to the Observables they’re interested in. This subscription typically involves storing the Observable’s current value in the component’s local state, and this is where the efficiency gains come in: only components subscribed to an Observable will re-render when that Observable emits a new value, no matter how deep in the react component tree they are.

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 Observables

Now, this how the same app looks like if we move the state inside an Observable:

App.tsx
3 collapsed lines
import { useEffect, useState } from "react";
import style from "./App.module.css";
import { useObservableState, type Observable, Subject } from "./observable";
export const App = () => {
const [counts] = useState(() => new Subject<number>());
useEffect(() => {
let count = 0;
const handle = setInterval(() => {
count = (count + 1) % 3;
counts.next(count);
}, 2000);
return () => clearInterval(handle);
}, [counts]);
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: Observable<number> }) => {
const [count] = useObservableState(counts, 0);
return <article className={style.AppArticle}>{count}</article>;
};
The Theme component with its border color fixed

We can see the Theme component is not being re-rendered anymore. By addressing these issues, we can create a more efficient rendering process where components only update when truly necessary, improving the overall performance and predictability of the application.

What is an Observable

As Carl Hewitt 1 once said:

One actor is no actor. They come in systems.

We could say an Observable is not something that exists in isolation, you also need an Observer, and a Subject. The three together form the foundation of the Observable pattern.

Key characteristics of Observables are:

  • Push-based model: Observables actively push new values to their subscribers whenever they have new data.
  • Explicit subscription: Observers must explicitly subscribe to an Observable to receive updates.
  • Coarse-grained reactivity: When an Observable emits a new value, all of its subscribers are notified, regardless of whether they need that specific update.
  • Synchronous/asynchronous: Observables can emit values synchronously and asynchronously.

Observable

An Observable represents a stream of data or events over time. It’s the source of information that can be observed. Think of it as a newsletter that you can subscribe to, continuously providing updates to its subscribers.

The stream created by an Observable may:

  1. Emit values indefinitely or emit no values at all.
  2. Be closed (completed), after which no more values can be emitted.
  3. Be closed with an error, after which it cannot be completed nor emit more values.

In code, we might define an Observable interface like this:

interface Subscription {
unsubscribe(): void;
}
interface Observable<T> {
subscribe(observer: Observer<T>): Subscription
}

Observer

An Observer is the counterpart to an Observable. It’s the entity that listens to the Observable, reacting to new data, errors, or the completion of the data stream. If an Observable is a newsletter, the Observer is the reader who processes each new issue.

The Observer interface typically looks like this:

interface Observer<T> {
next(value: T): void;
error?(err: any): void;
complete?(): void;
}

Subject

A Subject is a special type of Observable that acts as both an Observable and an Observer. It can multicast to multiple Observers, making it particularly useful for broadcasting values to multiple parts of an application.

Here’s a basic interface for a Subject:

interface Subject<T> extends Observable<T> {
next(value: T): void;
error(err: any): void;
complete(): void;
}

A Subject implements both the Observable methods (like subscribe) and the Observer methods (next, error, complete). This dual nature allows it to both receive values and emit them to its observers.

In practice, Subjects are often used as a bridge between the non-reactive world and the reactive world of Observables. They can take a simple value or event and turn it into a stream that Observers can subscribe to.

These three components - Observable, Observer, and Subject - work together to create a system for managing streams of data:

  • Observables provide a consistent interface for working with asynchronous data, whether it’s user events, HTTP responses, or state changes in an application
  • Observers provide a structured way to react to these streams of data
  • Subjects tie it all together, allowing for easy multicasting of values to multiple interested parties

Poor man’s implementation

Let’s show a simple implementation for the Observable, Observer, and Subject interfaces, as well as for the useObservableState hook.

observable.ts
export interface Observer<T> {
next(value: T): void;
error?(error: unknown): void;
complete?(): void;
}
export interface Subscription {
unsubscribe(): void;
}
type Dispose = Subscription["unsubscribe"];
export interface Observable<T> {
subscribe(observer: Observer<T>): Subscription;
}
export class BasicObservable<T> implements Observable<T> {
36 collapsed lines
private completed = false;
private errored: unknown = undefined;
constructor(private producer: (observer: Observer<T>) => Dispose) {}
subscribe(observer: Observer<T>): Subscription {
let unsubscribe = () => undefined;
if (this.completed) {
observer.complete?.();
return { unsubscribe };
}
if (this.errored) {
observer.error?.(this.errored);
return { unsubscribe };
}
const dispose = this.producer({
next: observer.next,
complete: () => {
this.completed = true;
dispose();
},
error: (error: unknown) => {
this.errored = error;
dispose();
},
});
return {
unsubscribe: () => {
if (!this.completed && !this.errored) {
dispose();
}
},
};
}
}
export class Subject<T> implements Observable<T> {
43 collapsed lines
private observers: Set<Observer<T>[]> = new Set();
private isCompleted = false;
private errorState: unknown = undefined;
next(value: T): void {
if (this.isCompleted || this.errorState) return;
this.observers.forEach((observer) => observer.next(value));
}
error(err: any): void {
if (this.isCompleted || this.errorState) return;
this.errorState = err;
this.observers.forEach((observer) => observer.error?.(err));
this.observers.clear(); // Avoid loitering by removing observers after error
}
complete(): void {
if (this.isCompleted || this.errorState) return;
this.isCompleted = true;
this.observers.forEach((observer) => observer.complete?.());
this.observers.clear(); // Avoid loitering by removing observers after completion
}
subscribe(observer: Observer<T>): Subscription {
let unsubscribe = () => undefined;
if (this.isCompleted) {
observer.complete?.();
return { unsubscribe };
}
if (this.errorState) {
observer.error?.(this.errorState);
return { unsubscribe };
}
this.observers.push(observer);
return {
unsubscribe: () => {
this.observers.delete(observer);
},
};
}
}
export function useObservableState<T>(observable: Observable<T>): [T | undefined];
export function useObservableState<T>(observable: Observable<T>, initialValue: T): [T];
export function useObservableState<T>(subject: Subject<T>, initialValue: T): [T, (newState: T) => void];
export function useObservableState<T>(observable: Observable<T> | Subject<T>, initialValue?: T) {
const [state, setState] = useState(initialValue);
useEffect(() => {
const subscription = observable.subscribe({
next: setState,
});
return () => subscription.unsubscribe();
}, [observable]);
if ("next" in observable) {
return [state, observable.next];
}
return [state];
}

Conclusions

The Observable pattern provides a powerful way to manage asynchronous data flows and state in React applications. It allows for more granular control over which components receive updates and when they re-render. By moving state management outside of React’s built-in system, we can achieve better performance and more predictable behavior, especially in complex applications with deeply nested component trees.

Observables enable components to subscribe only to the specific pieces of state they need, reducing the cascade of re-renders that often occurs with prop drilling or context-based state management.

As applications grow in complexity, the benefits of using Observables for state management become more pronounced, making it easier to maintain performance and code clarity.

Yet, we have only have scratched the tip of the iceberg, in order to have a production-ready Observables library such as RxJS we need to consider things like:

  • Implementing higher-order Observables (Observables that emit other Observables) for more complex state management scenarios, e.g.: switchAll , takeUntil
  • Add algebraic operations that transform Observables,e.g.: map
  • Implement a mechanism to batch multiple rapid updates from Observables to prevent excessive re-renders in React components, e.g.: debounceTime
  • Develop testing utilities specifically for your Observable implementation. Checkout Marble Testing
  • Implement hot Observable variant.

Footnotes

  1. https://en.wikipedia.org/wiki/Carl_Hewitt Carl Hewitt ↩