React state with the observer pattern
The most common performance issue in React is state that is not close enough to where it is used causing re-rendering of component that don’t use that state. This can often be fixed by lifting the components that don’t use the state higher in the component hierarchy or pushing the state down into a child component.
Sometimes it is unavoidable to have a UI element that needs to trigger changes in a child component. This often leads devs to reach for complicated state management libraries like Redux but today we will explore using an observable. An observable is an object that notifies subscribers of changes. There are plenty of observable libraries like RxJs and signals but we will roll or own simple subject to keep it lightweight and dependency free.
export class Subject<T> {
private subs: Array<(value: T) => void> = [];
constructor(public value: T) {}
subscribe(callback: (value: T) => void) {
this.subs.push(callback);
callback(this.value);
return callback;
}
unsubscribe(callback: (value: T) => void) {
this.subs = this.subs.filter((c) => c !== callback);
}
next(value: T) {
this.value = value;
this.subs.forEach((callback) => {
callback(value);
});
}
}
This TypeScript class gives us everything we need for a simple subject. It keeps an internal array of subscriber functions, the current value and methods to subscribe, unsubscribe and update the value and notify all subscribers. To use it we create a new instance with an initial value. The subscribe method returns the callback function so we can keep a reference to unsubscribe later. Each time the next method is called all subscribers are notified.
const subject = new Subject(0);
const callback = subject.subscribe((value) => {
console.log(value);
});
subject.next(1);
subject.next(2);
subject.unsubscribe(callback);
subject.next(3);
This will log 0, 1 and 2 but not 3 as we unsubscribed before the next method was called.
So how does this help with React state and unnecessary re-rendering?
Lets create a hook that subscribes to the subject and returns the value in a state variable.
import { useEffect, useState } from 'react';
import { Subject } from './subject';
export function useSubjectState<T>(subject: Subject<T>) {
const [value, setValue] = useState(subject.value);
useEffect(() => {
const callback = subject.subscribe((value: T) => {
setValue(value);
});
return () => {
subject.unsubscribe(callback);
};
}, [subject]);
return value;
}
It is a simple state object with a useEffect that subscribes and unsubscribes to the subject updating the state each time the subject is updated.
Now we can use this hook to return state that does cause the component to re-render when the subject is updated.
import { Subject } from './subject';
import { useSubjectState } from './useSubjectState';
export function Child({ subject }: { subject: Subject<number> }) {
const value = useSubjectState(subject);
return <>{value}</>;
}
We can pass a subject to our child and then have the hook return the state for us.
Next we need an instance of a subject so lets create a hook that creates the instance.
import { useRef } from 'react';
import { Subject } from './subject';
export function useCreateSubject<T>(initialValue: T) {
const subjectRef = useRef<Subject<T>>(undefined);
if (!subjectRef.current) {
subjectRef.current = new Subject(initialValue);
}
return subjectRef.current;
}
This allows us to create an instance of a subject we can use to pass around to our child components. We need to have the same instance of the subject for each re-render.
function Parent() {
const subject = useCreateSubject(0);
return (
<>
<button onClick={() => subject.next(subject.value + 1)}>Inc</button>
<!-- Anything here will not re-render -->
<Child subject={subject} />
<br />
<Child subject={subject} />
<!-- Anything here will not re-render -->
</>
);
}
Now we have a lightweight object that doesn’t cause a re-render. It can be passed around to child components and can be created without any boiler plate or complicated state management libraries. This keeps the re-renders to where the subjects data is displayed.