loading...

1 year with React Hooks - Biggest lesson learned

michi profile image Michael Z Originally published at michaelzanggl.com ・3 min read

I've been working with React Hooks for over one year now. Working on a variety of things, there has been one glaring issue that I've run into, not once, but twice.

The issue has to do with useEffect, the hook used to handle any side effects.

I prepared a super simplified version of the problem:

In this example you pick some technologies, click "send request" and see the output. Imagine we are fetching an API, the data that comes back is an array where the indices correspond to the selected elements.

{response.map((item, index) => (
    <div key={item}>
        {appliedElements[index].toUpperCase()}: {item}
    </div>
))}

And if there is any change in the input, we have a useEffect-hook to clean up the output.

React.useEffect(() => {
    setResponse([]);
  }, [appliedElements]);

Now, with the output displayed, try removing a selected element again. It will crash. It will crash because of appliedElements[index].toUpperCase().

What happens is:

  1. Click on the selected element will remove it from the state and trigger a rerender
  2. component gets rerendered (and crashes because the applied element no longer exists for the index)
  3. useEffect callback gets run

Coming from the world of Vue, adding a watch over a property and resetting the output there will actually work just fine. But this is not how useEffect works, so what's the best way to fix this?

There are actually 4 different ways you might approach this.

One thing I'd like to mention is the solution to wrap the API call and transform the response the way you need it later. That's definitely the best way, but where I faced the problem, this wasn't possible. Input and output weren't a 1-to-1 correlation like here...

useLayoutEffect

Actually... this doesn't help. Just wanted to get it out of the way. The component will still rerender in step 2. It just won't be painted right away.

Patch it up

Of course, one way would be to simply patch it, basically checking if appliedElements[index] exists before trying to render the row. But that is not fixing the root cause, so let's skip it...

useMemo

const renderedResponse = React.useMemo(() => {
    return response.map((item, index) => (
      <div key={item}>
        {appliedElements[index].toUpperCase()}: {item}
      </div>
    ))
}, [response]);

This way we simply memoize the response. The useEffect is still there to clean up the response. And if we remove an element, it won't trigger the callback again (and crash...) because appliedElements is not part of the dependency array. Wait... isn't that a bad thing though? Yea, in fact, you will get the following lint error.

React Hook React.useMemo has a missing dependency: 'appliedElements'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)

This can cause hard to track bugs further down the route, so let's see if we can do something else...

useReducer

This was basically the response I got from everyone I asked. But it didn't feel right... useState and useEffect alone should be powerful enough to handle this case correctly. Despite my doubts, I actually went with this approach but there were quite a few cases in which I had to reset the response. If I forgot one, it crashed again. Not really the best solution to handle the reset either...

The final solution

The solution I eventually implemented is surprisingly simple.

All I had to do was replace

const request = () => {
    // fetch some data...
    setResponse(appliedElements.map((e, i) => i * Math.random()));
};

with

const request = () => {
    // fetch some data...
    setResponse({
      output: appliedElements.map((e, i) => i * Math.random()),
      elements: appliedElements
    });
};

and

{response.map((item, index) => (
   <div key={item}>
     {appliedElements[index].toUpperCase()}: {item}
   </div>
))}

with

{response.output.map((item, index) => (
   <div key={item}>
     {response.elements[index].toUpperCase()}: {item}
   </div>
))}

So now when we set the response, we also save a snapshot of the applied elements next to it. This way, when we remove a selected element, it will only be removed from appliedElements, but not from the snapshot inside response. With this, input and output are completely separated. Of course, the input and output can still be inside a reducer if you want.

The funny thing about this solution is that this non-reactive approach is the default behavior with Vanilla Js. The app was overreacting.

Posted on by:

Discussion

markdown guide
 

Yeah using useEffect as a watch isn't really what it's for. Certainly not when regarding the internal logic of a component.

Your component is trying to reuse logic for two things and React isn't doing any magic to help you - so you could just fix your problem by setting the response to [] inside your toggle function as this is the moment where the logic breaks - or you can make the output self-contained so all the data is together (as you do in your conclusion).

As I'm sure you know, you can do state management using external libraries and React is pretty much only worried about the view - so Redux or a dozen other things including my library will help. In the case of mine actually providing you with immediate events when a value changes - basically giving you a watch. React doesn't have this under the covers - which certainly makes the learning experience an interesting one!

Here's a version that forces that explicit update for instance:

 

I like the simplicity, but there are so many pitfalls that lead to slow performance, etc. But that's more due to the way react renders things in general in functional components.