4

A more granular React useEffect | Bits and Pieces

 2 years ago
source link: https://blog.bitsrc.io/a-more-granular-useeffect-9c8ca3d9f634
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

A more granular useEffect

Ever needed to run an effect when certain dependencies changed, but not others?

useEffect is one of the most essentials React hooks. Whether you need to fetch data, update the DOM or run any other kind of “side effect”, useEffect allows you to execute code outside of the rendering loop, letting your app run smoothly.

If you’re not yet familiar with useEffect, I suggest you stop for a moment, read Using the Effect Hook and come back after a while.

useEffect and its perks

Unless you want your effect to run on every update, you pass an array of values to useEffect so that it only runs when these values change. This is called conditionally firing an effect. And it comes with a catch:

If you use this optimization, make sure the array includes all values from the component scope (such as props and state) that change over time and that are used by the effect. Otherwise, your code will reference stale values from previous renders.

Which means that you are not just conditionally firing an effect. You are in fact listing all the dependencies of the effect, and re-running the effect when any of the dependencies changes (so much for a condition!). And as the list of dependencies grows, the effect risks running more often.

Let’s see a few scenarios where this behaviour might be problematic.

Scenario 1: Trying to run an effect when some value changes, but not others

Suppose that we were to run some expensive operation based on a received value and then save the result into our state. The code would look something like this:

const [data, setData] = useState();useEffect(() => {
const result = expensiveOp(props.value);
setData(result);
}, [props.value]);

Because the effect uses the value passed as props, props.value is included in the dependency array passed to useEffect. When props.value changes, the effect runs again and the data is updated. All is well.

Now suppose that we needed to use another value to compute the result of the expensive operation. The effect would have to be changed to:

useEffect(() => {
const result = expensiveOp(props.value, props.other);
setData(result);
}, [props.value, props.other]);

The effect would now run each time either props.value or props.other changed. But what if we only wanted the effect to run when props.value changed? What if the other value was not significant enough to re-run the effect? Or worse, what if re-running the effect when props.other changed (and only props.other) led to a bug or had some performance overhead?

Scenario 2: Trying to call back a function when a value changes

Another scenario you might encounter involves calling a function when a value changes. This function could be an event handler or an imperative API. In all cases, the function should only be called when the value changes, not when the function itself changes.

In this scenario, the following code would not work as intended:

useEffect(() => {
if (callback) callback(value);
}, [value, callback]);

When the reference to the callback function changes, the effect runs again, which is not what we want.

Working around useEffect’s limitations

So what do we do when this happens? It might be tempting to omit the second value from the list of dependencies when calling useEffect. This would be a bad idea. Remember the catch earlier? Your code will reference stale values from previous renders. And we might not even notice it until the app starts exposing some strange behaviour that will take us days to debug.

If you haven’t done it yet, you should install the react-hooks plugin for ESLint. It will warn you whenever you violate one of the rules of hooks or when you forget to list all the necessary dependencies.

So what do we do then? Our first reflex should be to refactor the code. 80% of the times, we might be able to avoid the issue in the first scenario by splitting the effect into smaller effects, so we can break down the list of dependencies and isolate the time consuming operations.

In the second scenario, we might be able to trigger the call back function in response to an actual event without using useEffect (eg: if the value changes as the result of a user input, we could attach an event handler to the input field).

But then there are the other 20%. These special cases will really get under your skin. Sometimes the cost of refactoring is just too much, or there is no obvious way to change the code. This happened to me a few times.

useGranularEffect to the rescue

If you Google the problem, you’ll probably find this on StackOverflow: React useEffect Hook when only one of the effect’s deps changes, but not the others. When I read the original answers, I found that a few of them were interesting, but none of them were as easy to use as useEffect. So I compiled a few good ones together and ended up proposing useGranularEffect as an anwser. The signature of the hook is the same as useEffect, except that the list of dependencies is split into two: primary and secondary dependencies. And as you may have guessed, the effect will only run when one of the primary dependencies has changed. A change to a secondary dependency has no effect.

With useGranularEffect, the previous examples can be rewritten as:

useGranularEffect(() => {
const result = expensiveOp(props.value, props.other);
setData(result);
}, [props.value], [props.other]);
useGranularEffect(() => {
if (callback) callback(value);
}, [value], [callback]);

It’s that simple. All we need to do is split the array of dependencies into two. useGranularEffect also ensures that all the dependencies are up to date when the effect runs (there’s no risk of referencing a stale value). We may also return a cleanup function (just like with useEffect), which will be executed only after the primary dependencies have changed.

useGranularEffect explained

So what’s under the hood — or I should say, the hook 🤦‍♂? Not much really.

Let’s look into the code:

function useGranularEffect(effect, primaryDeps, secondaryDeps){
const ref = useRef();

if (!ref.current || !primaryDeps.every((d, i) => Object.is(d, ref.current[i]))) {
ref.current = [...primaryDeps, ...secondaryDeps];
}

return useEffect(effect, ref.current);
};

The source code is available on GitHub, complete with types and unit tests.

Let’s start by the end! We can see that useGranularEffect uses useEffect to run the effect. But it does not directly pass any of the arrays it receives in parameter. No, instead it passes ref.current as dependencies. What’s in there?

current is the value of the object called ref which is created at the first line with useRef. If you’re not familiar with the hook, useRef returns a mutable object which persists for the full lifetime of the component. This is handy for keeping any mutable value around between updates.

In our case, the object is used to store the complete list of dependencies (primary plus secondary):

ref.current = [...primaryDeps, ...secondaryDeps];

But we don’t just set ref.current at any time. We only set it at initialisation time (when ref.current is undefined) and when one of the primary dependencies changes:

!primaryDeps.every((d, i) => Object.is(d, ref.current[i]))

The above code goes through every primary dependency in the array (primaryDeps.every(...)) and checks that it is the same as the previous one (Object.is). This works because ref.current holds the values of the previous dependencies and the primary dependencies are in the same order as in primaryDeps (because ref.current = [...primaryDeps, ...secondaryDeps]).

If all of the primary dependencies are the same, ref.current is not updated, so the effect does not run. However, if they are not all the same, ref.current is updated with the current dependencies (primary and secondary), causing the effect to run again with fresh values.

And here we have a simple and elegant solution to our problem!

If you would like to use useGranularEffect in your code, you can install granular-hooks via npm:

npm install granular-hooks

Then import it with:

import { useGranularEffect } from 'granular-hooks';

The package is tiny and it has no dependencies.

Note 1: useMemo and useCallback are two other common hooks that take an array of dependencies in parameter. They do not have their granular version yet, but they shall soon be added to the library.

Note 2: useGranularEffect does not have an ESLint plugin (yet), so make sure you remember to list all the dependencies used in the effect when calling it.

Unlock 10x development with independent components

Building monolithic apps means all your code is internal and is not useful anywhere else. It just serves this one project. And as you scale to more code and people, development becomes slow and painful as everyone works in one codebase and on the same version.

But what if you build independent components first, and then use them to build any number of projects? You could accelerate and scale modern development 10x.

OSS Tools like Bit offer a powerful developer experience for building independent components and composing modular applications. Many teams start by building their Design Systems or Micro Frontends, through independent components. Give it a try →

An independent product component: watch the auto-generated dependency graph

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK