React: Fine-grained reactivity should be the norm
9 min read —
Tags:
frontend
webdev
react
javascript
This article attempts to explain why the current state of React has fundamental flaws so we can slowly build the knowledge to understand how to overcome them, for both better user and developer experience.
Disclaimer: We will need to simplify some concepts a little bit for didactic reasons, but you'll find a list with a lot of great content in the references section, in case you're curious. Also, you should have some familiarity with React, its Virtual Dom, and basic hooks.
Table of Contents
- If you wanna try yourself
- React rendering model is flawed
- Introducing Fine-grained reactivity 🤌
- The easiest way to achieve fine-grained reactivity in React
- Conclusion
- References
If you wanna try yourself
First, make sure you have React developer tools installed in your browser and check the Highlight updates when components render option.
Second, here is the repo with all examples. You can simply clone it and follow the instructions on the readme.
We'll be using an application created with create-next-app
configured with Typescript and TailwindCSS.
Now, let's go!
React rendering model is flawed
Don't get me wrong: React is amazing, robust and has been giving life to all sorts of projects for over a decade. But that's precisely why people are able to detect its flaws and are so passionate about improving it (or coming up with a new competitor once in a while).
So, the problem we are going to discuss today is rendering. React rendering model basically makes a whole component and its children re-render on every state change. Let's take a look at a very basic example:
import { useState } from "react";
import Button from "@/components/Button";
export default function DefaultCounter() {
const [count, setCount] = useState(0);
return (
<div className="bg-green-900 p-2 rounded-md">
<h2 className="text-xl text-white">Default counter</h2>
<p className="text-white">Counter: {count}</p>
<Button onClick={() => setCount(count + 1)}>Increase</Button>
</div>
);
}
Every time we click on the button, the count increases (the state changes), which causes a re-render in every child of our DefaultCounter
component. Notice the highlight in our Button
.
Here is another example, passing props to a child counter display and an unrelated component. Passing props or not is irrelevant in this case.
import { useState } from "react";
import UnrelatedElement from "@/components/UnrelatedElement";
import Button from "@/components/Button";
export default function CounterPassingProp() {
const [count, setCount] = useState(0);
return (
<div className="bg-green-900 p-2 rounded-md">
<h2 className="text-xl text-white">Outer Counter passing prop</h2>
<p className="text-white">Counter: {count}</p>
<Button onClick={() => setCount(count + 1)}>Increase</Button>
<InnerCounter count={count} />
<UnrelatedElement />
</div>
);
}
type Props = {
count: number;
};
export function InnerCounter({ count }: Props) {
return (
<div className="bg-green-600 p-2 rounded-md my-2">
<h2 className="text-xl text-white">Inner counter receiving prop</h2>
<p className="text-white">Counter: {count}</p>
</div>
);
}
Again, everything is re-rendering.
We could maybe try to substitute the counter state for a ref, since state is the cause of re-rendering, right?
export default function CounterWithRef() {
const count = useRef(0);
return (
<div className="bg-green-900 p-2 rounded-md">
<h2 className="text-xl text-white">Counter using ref</h2>
<p className="text-white">Counter: {count.current}</p>
<Button onClick={() => count.current++}>Increase</Button>
</div>
);
}
Well... nothing seems to happen because ref doesn't force any part of the component to re-render, so we can't see the updated value of the counter on the screen.
Could we use some hack like directly modifying innerHTML
? Maybe, but that's totally an anti-pattern. A way to force a re-render would be creating a new state like:
const [, setForceUpdate] = useState(Date.now());
And then calling setForceUpdate
when the click event is triggered, which also completely kills our purpose and causes a lot of confusion. Please, don't do it!
Those performance problems are only partially fixable and the cost in complexity for something like that doesn't make much sense. In our example, specifically, the best we could do is to wrap the elements that don't have props changing with memo
:
const MemoUnrelatedElement = memo(UnrelatedElement);
// and then we use inside our Counter component
<MemoUnrelatedElement />
From the docs:
memo
lets you skip re-rendering a component when its props are unchanged.
Now UnrelatedElement won't re-render. Nice!
But what about the other children?
- Wrapping InnerCounter with
memo
, would be irrelevant since it receives the count state as a prop, which is changing. - Wrapping Button with
memo
would also be irrelevant. Button receives the setter function as a prop, which has changed.
What about abstracting the setter passed to Button and wrapping it with the useCallback hook?
const increment = useCallback(() => {
setCount(count + 1);
}, [count]);
// and then
<Button onClick={increment}>increment</Button>
It won't work because it was not designed for it. The default behavior of React is to recreate increment
declaration in every render, pointing to a new reference (remember: functions are not primitive values, so "same implementation" doesn't mean "same function").
useCallback was created to cache that declaration, making sure we are pointing to the same reference, unless some dependency changes, which is exactly our case.
This is the best we've got so far:
There must exist a better solution, and luckily, it does!
Introducing Fine-grained reactivity 🤌
Trying to come up with a definition, I've found it quite difficult to express it formally. A very vague definition would be "an architectural approach that focuses on efficiently updating and rendering user interfaces in response to changes in the underlying data or state."
But that doesn't say anything at all.
First: Observer Pattern! 👀
In order to learn how fine-grained reactivity is implemented, we need to understand the concept of Observers.
Simplified as much as possible, this design pattern contains two types of entities:
- Subject: an entity that contains a list of observers for each type of event
- Observers: entities subscribed to the Subject that get notified by it based on a type of event
Think of the Subject as a job portal that sends you notifications with open positions based on your profession.
subject.notify("software-dev");
You, as an Observer subscribed on the "software-dev" list, will do something with that information, like opening the link to the open position's page.
observer.update();
For real-world examples with detailed implementation, I highly recommend reading this explanation, but the idea of notifying a list of observers based on a type of event is enough to get the following topics.
Coming up with a better definition
Fine-grained reactivity aims for performance. The goal is to track changes and re-render the UI as least as possible, through a graph full of nodes. Those nodes here are conceptually called Primitives. Let's take a look at each one of them.
- Signals: observables (event emitters), which we can access their values through getters and setters.
- Reactions: observe our Signals and re-run them every time the Signal's value changes.
- Derivations: memoization mechanisms that cache expensive derived values.
So you can see this technique as turning the states into observables in a graph that notifies changes in any children.
Those concepts are widely implemented in other frameworks like Qwik, Solid.js, and Preact. Each one approaches reactivity with different mechanisms that take care of unused nodes, clean-ups, keeping the graph as shallow as possible, ensuring synchronous execution through transactions, etc. I won't dare to touch those subjects for now.
The easiest way to achieve fine-grained reactivity in React 🔥
Disclaimer: that's solely my point of view.
Legend State is a state-management library that achieves that level of efficiency. It exposes an API with the primitives discussed above through custom hooks and wrappers for our components.
Let's rebuild our simple counter:
import { ObservablePrimitiveBaseFns } from "@legendapp/state";
import { useObservable, Memo } from "@legendapp/state/react";
import UnrelatedElement from "@/components/UnrelatedElement";
import Button from "@/components/Button";
export default function FineGrainedCounter() {
const count$ = useObservable(0);
return (
<div className="bg-green-900 p-2 rounded-md">
<h2 className="text-xl text-white">Legend Counter</h2>
<p className="text-white">
Counter: <Memo>{count$}</Memo>
</p>
<Button onClick={() => count$.set((prev) => prev + 1)}>increment</Button>
<InnerCounter count$={count$} />
<UnrelatedElement />
</div>
);
}
type Props = {
count$: ObservablePrimitiveBaseFns<number>;
};
export function InnerCounter({ count$ }: Props) {
return (
<div className="bg-green-600 p-2 rounded-md my-2">
<h2 className="text-xl text-white">Inner counter receiving prop</h2>
<p className="text-white">
Counter: <Memo>{count$}</Memo>
</p>
</div>
);
}
useObservable
is a custom hook that creates a Signal count$ with get
, set
and value
. In this case, our previous count state. The $ (dollar sign) at the end of the variable is a convention.
Mind-blowing 🤯
Also, let's get an example with derivations. Here is probably the most used example in the history of computed values:
import { useState } from "react";
export default function DefaultFullName() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
return (
<div className="bg-green-900 p-2 gap-2 flex flex-col rounded-md w-[30ch]">
<h2 className="text-xl text-white">Default computed</h2>
<FirstName handleChange={(e) => setFirstName(e.target.value)} />
<LastName handleChange={(e) => setLastName(e.target.value)} />
<p className="text-white">
Full name: {firstName} {lastName}
</p>
</div>
);
}
function FirstName({ handleChange }) {
return (
<>
<label htmlFor="lastName" className="text-white">
Last name:
</label>
<input id="lastName" onChange={handleChange} />
</>
);
}
function LastName({ handleChange }) {
return (
<>
<label htmlFor="firstName" className="text-white">
First name:
</label>
<input id="firstName" onChange={handleChange} />
</>
);
}
As we saw before, everything is going to re-render on every state change:
Now, the same component with Legend State:
import { useObservable, observer, Memo } from "@legendapp/state/react";
import { ObservableObject } from "@legendapp/state";
const FullName = ({
user,
}: {
user: ObservableObject<{
firstName: string;
lastName: string;
}>;
}) => (
<p className="text-white">
Full name: <Memo>{user.firstName}</Memo> <Memo>{user.lastName}</Memo>
</p>
);
const FirstName = observer(
({
user,
}: {
user: ObservableObject<{
firstName: string;
lastName: string;
}>;
}) => (
<div className="flex flex-col gap-2">
<label htmlFor="firstName" className="text-white">
First Name
</label>
<input
id="firstName"
value={user.firstName.get()}
onChange={(e) => user.firstName.set(e.target.value)}
/>
</div>
)
);
const LastName = observer(
({
user,
}: {
user: ObservableObject<{
firstName: string;
lastName: string;
}>;
}) => (
<div className="flex flex-col gap-2">
<label htmlFor="lastName" className="text-white">
Last Name
</label>
<input
id="lastName"
value={user.lastName.get()}
onChange={(e) => user.lastName.set(e.target.value)}
/>
</div>
)
);
export default function FineGrainedFullName() {
const user = useObservable({
firstName: "",
lastName: "",
});
return (
<div className="bg-green-900 p-2 gap-2 flex flex-col rounded-md w-[30ch]">
<h2 className="text-xl text-white">Fine Grained Full Name</h2>
<FirstName user={user} />
<LastName user={user} />
<FullName user={user} />
</div>
);
}
Note how the renders are isolated to the input components changing their state - in this case, their signal's value. And how the full name field also doesn't trigger a re-render even dynamically being updated!
Legend does way more than that: It simplifies context, global state, persistency, reference values, and much more, but we'll keep that for a future article.
Conclusion
That's it, devs! If you wanna deep-dive on the subject, I can't recommend the links on the references section enough. 🤓
Thank you so much for reading.
References
https://refactoring.guru/design-patterns/observer/typescript/example https://indepth.dev/posts/1269/finding-fine-grained-reactive-programming#how-it-works https://legendapp.com/open-source/legend-state/ https://legendapp.com/open-source/state/fine-grained-reactivity/ https://www.builder.io/blog/usesignal-is-the-future-of-web-frameworks https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf https://medium.com/hackernoon/becoming-fully-reactive-an-in-depth-explanation-of-mobservable-55995262a254 https://preactjs.com/blog/introducing-signals/ https://hygraph.com/blog/react-memo