Observable in React
The observer pattern is a software design pattern in which an object, named the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes, usually by calling one of their methods.
The observer pattern is surprisingly well suited for use in React state management.
We can use observable object in React view by using the following useObservable
hook.
export function useObservable(subject) {
const [_, setk] = React.useState(0);
React.useEffect(() => {
subject.addObserver(() => setk(c => c + 1));
return () => {
subject.removeObserver(setk);
};
}, [subject]);
return subject;
}
The view will get a forced update whenever subject.notify()
is triggered.
The benefit of this comparing to simply use useState
is that, the entire paradigm of Object Oriented Programming can be used to manage the state. This makes managing complex state very easy. It also greatly decouple the state and business logic management from the view thus making it extremely easy to write robust tests of the application.
One example is the tree select.
Tree select uses tree structure in the form of JSON. Usual way to implement it might be rendering node recursively according to the tree and pass the label and value of the node as props to each element.
In order to implement the interactivity, we have to maintain some state in each node. In this case, it's the checked state. So the checkbox element in each node element is controlled by the state. When checkbox is clicked, it has to update its own state and all the descendants' states. For descendants, it shouldn't be updated by triggering the same event as the original target, as it will recursively trigger the descendants many times. So the ideal way is to add a checked state to the prop and pass it down. The node has to reconcile the change of checked prop and checked state in some way.
In addition to the downwards propagation, it also needs to notify the parent that some of its children's state has changed, and it should check children again to update its own checked state. The easiest way to update parent is to define a callback called onChildUpdated and pass to the children. Child node can use onChildUpdated to update the parent. Then the parent can in turn call its own onChildrenUpdated passed from its parent. But this has not to be mixed with its own version.
We can see that this logic is highly coupled with React's way of passing data around. And the view is actually a close mirror of the data structure.
Better way
Instead of defining all the callbacks and states inside the view, a much better way is to externalise them in a model object, say Node.
class Node extends Observable {
children = [];
value = null;
title = "";
// UI state
parent = null;
checked = false;
indeterminate = false;
}
Observable class is just a simplified implementation of observable pattern.
class Observable {
observers = [];
addObserver(ob) {
this.observers.push(ob);
}
notify() {
for (const ob of this.observers) {
ob();
}
}
removeObserver(ob) {
const i = this.observers.indexOf(ob);
if (i >= 0) {
this.observers.splice(i);
}
}
}
We use addObserver
to hook the model to the view. And use notify
to manually enforce a view update.
Next, we can easily implement the required methods in the Node
class. Only 3 methods are needed.
class Node extends Observable {
...
// set own checked state and update downwards and upwards
setChecked(checked) {}
// set descendants checked state to own checked
checkAllDescendants() {}
// handle when child state is changed
onChildChecked() {}
}
We call this.notify()
whenever the object property is set. If we worry that there will be too many updates, we can debounce the notify function.
Now we can see that all the logics are inside Node class which is an ordinary javascript class without any link to React ecosystem. We can apply all the traditionally programming methodologies to this sort of classes. For example, having more complicated object relationships, mixins, and getter/setter. More importantly, we can test the class vigorously.
After we restructured using Observable, our view component has become very lean.
const Tree = function ({ tree }) {
const m = useObservable(tree);
return (
<div style={{ marginLeft: 10 }}>
<label>
<input
type="checkbox"
checked={m.checked}
onClick={() => m.setChecked(!m.checked)}
/>
{m.title}
</label>
{m.children.map((c) => {
return <Tree tree={c} key={c.value} />;
})}
</div>
);
};
As you can see, we use object method to change the state of the node which in turn change the state of the tree, without touching React view.
One interesting thing is that for each node, when it's updating the view, the object reference to m
is unchanged. This is key difference of using Observable. We are not doing any change detection of the state.
However, since we are doing multiple update in different node, there might be duplicated updates. But fret not, those updates can be optimised by React. If we really worry about performance, we can also use React.memo
so it prevent the node view updates that is propagated from the top. By using React.memo
, it essentially 2-way binds the view to the model like what Vuejs does.
The implementation is here. This optimised version updates the view minimally.
Using observable to manage view state is a very good solution. It segregates the complex state managements and makes testing easier. This pattern actually can trace back to the Model-View pattern a decade ago. But as a state management solution, can it replace other type of solutions like redux or context API? I think it is possible. We'll just need to have a bigger model to cover more scopes. But that also means we lose the benefit of accessing global or regional scope directly. However, we can still do that by using storing Observable in the context API. Then context API only needs to manage one state without other action dispatches and so on. Same goes for redux.
Afternote
After writing this article and I went to do some research on why this type of solution wasn't known to me before. To my surprise, it was already a popular pattern called Mobx. I will definitely try it next time.