1 year with React Hooks - Biggest lesson learned

Published 7/15/2020

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: https://codesandbox.io/s/react-output-useeffect-orto4

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.

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

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