DEV Community

Cover image for React: Fine-grained reactivity should be the norm
Gabriel Afonso
Gabriel Afonso

Posted on

React: Fine-grained reactivity should be the norm

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

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Cursor clicking on the button, incrementing the counter and making everything re-render

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Again, everything is re-rendering.

Cursor clicking on the button, incrementing the counter and making everything re-render

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Cursor clicking on the button, but nothing happens

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());
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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:

Cursor clicking on the button, incrementing the counter and making everything re-render, except UnrelatedElement

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

Image description

Think of the Subject as a job portal that sends you notifications with open positions based on your profession.

subject.notify("software-dev");
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Cursor clicking on the button, incrementing the counter but nothing re-renders

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

As we saw before, everything is going to re-render on every state change:

Filling a form with two fields and all elements re-render on every key press

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Filling a form with two fields and only the active one re-renders on every key press

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

Top comments (6)

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Thank you for this great piece! It is the perfect example of why React should be EOL'ed.

So, to partially fix an inherent library problem in the most used example for derivative calculation in the world, one must go from 35 lines of code to 70 lines of code.

React is out of control. It needs to be shot and put to rest.

Collapse
 
gabrielprrd profile image
Gabriel Afonso

I agree that it is out of control 😂 And those examples don't even dare enter into the weird realm of the useEffect hook, for instance (even though things got slightly simpler with react server components).

I honestly don't know if there is a solution in React's roadmap, but their current rendering model creates unnecessary complexity and performance issues compared to newer frameworks like SolidJs.

With the number of libs adopting signals, maybe they will come up with their own approach soon, so we don't have to rely on Legend, Jotai, or preact-signals.

Thank you for your comment, José 😁

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

My personal favorite is Svelte, and probably will be for the foreseeable future. It is super simple, and have you seen its performance benchmarks? No, no: Have you seen the preliminary Svelte v5 benchmarks? Svelte 5 will be very close to what Vanilla JS can do.

In any case, well done with the article. Very well explained. It is too bad React is what it is nowadays.

Collapse
 
bcsk profile image
bcsk

Thank you for providing this comprehensive table of contents! It's immensely helpful for navigating through the content and understanding the structure of your discussion on React rendering and fine-grained reactivity. Looking forward to diving into the details. 🙌 mickeyminors

Collapse
 
pedrotainha profile image
Pedro Tainha

Nice article. What tool do you use for see that rendering outlines in your gifs?
Thanks in advance

Collapse
 
pedrotainha profile image
Pedro Tainha

Oh sorry, only notice now that is at the very beginning of the article. I jumped that part when reading

"First, make sure you have React developer tools installed in your browser and check the Highlight updates when components render option."

Some comments may only be visible to logged-in visitors. Sign in to view all comments.