4

This is a more concise version of a question I raised previously. Hopefully, it's better explained and more understandable.

Here's a small app that has 3 inputs that expect numbers (please disregard that you can also type non-numbers, that's not the point). It calculates the sum of all displayed numbers. If you change one of the inputs with another number, the sum is updated.

App capture

Here's the code for it:

import { useCallback, useEffect, useState } from 'react';

function App() {

  const [items, setItems] = useState([
    { index: 0, value: "1" },
    { index: 1, value: "2" },
    { index: 2, value: "3" },
  ]);

  const callback = useCallback((item) => {
    let newItems = [...items];
    newItems[item.index] = item;
    setItems(newItems);
  }, [items]);

  return (
    <div>
      <SumItems items={items} />
      <ul>
        {items.map((item) =>
          <ListItem key={item.index} item={item} callback={callback} />
        )}
      </ul>
    </div>
  );
}


function ListItem(props) {

  const [item, setItem] = useState(props.item);

  useEffect(() => {
    console.log("ListItem ", item.index, " mounting");
  })

  useEffect(() => {
    return () => console.log("ListItem ", item.index, " unmounting");
  });

  useEffect(() => {
    console.log("ListItem ", item.index, " updated");
  }, [item]);

  const onInputChange = (event) => {
    const newItem = { ...item, value: event.target.value };
    setItem(newItem);
    props.callback(newItem);
  }

  return (
    <div>
      <input type="text" value={item.value} onChange={onInputChange} />
    </div>);
};

function SumItems(props) {
  return (
    <div>Sum : {props.items.reduce((total, item) => total + parseInt(item.value), 0)}</div>
  )

}

export default App;

And here's the console output from startup and after changing the second input 2 to 4:

ListItem  0  mounting App.js:35
ListItem  0  updated App.js:43
ListItem  1  mounting App.js:35
ListItem  1  updated App.js:43
ListItem  2  mounting App.js:35
ListItem  2  updated App.js:43
ListItem  0  unmounting react_devtools_backend.js:4049:25
ListItem  1  unmounting react_devtools_backend.js:4049:25
ListItem  2  unmounting react_devtools_backend.js:4049:25
ListItem  0  mounting react_devtools_backend.js:4049:25
ListItem  1  mounting react_devtools_backend.js:4049:25
ListItem  1  updated react_devtools_backend.js:4049:25
ListItem  2  mounting react_devtools_backend.js:4049:25

As you can see, when a single input is updated, all the children are not re-rendered, they are first unmounted, then re-mounted. What a waste, all the input are already in the right state, only the sum needs to be updated. And imagine having hundreds of those inputs.

If it was just a matter of re-rendering, I could look at memoization. But that wouldn't work because callback is updated precisely because items change. No, my question is about the unmounting of all the children.

Question 1 : Can the unmounts be avoided ?

If I trust this article by Kent C. Dodds, the answer is simply no (emphasis mine) :

React's key prop gives you the ability to control component instances. Each time React renders your components, it's calling your functions to retrieve the new React elements that it uses to update the DOM. If you return the same element types, it keeps those components/DOM nodes around, even if all* the props changed.

(...)

The exception to this is the key prop. This allows you to return the exact same element type, but force React to unmount the previous instance, and mount a new one. This means that all state that had existed in the component at the time is completely removed and the component is "reinitialized" for all intents and purposes.

Question 2 : If that's true, then what design should I consider to avoid what seems unnecessary and causes issues in my real app because there's asynchronous processing happening in each input component?

ptrico
  • 1,049
  • 7
  • 22
  • They're not unmounted, they're rerendered. Try using `useEffect(() => ..., [])` – Jonas Wilms Sep 20 '21 at 09:04
  • You might also want to have a look at [React.memo](https://reactjs.org/docs/react-api.html) to prevent the items from rerendering – Jonas Wilms Sep 20 '21 at 09:06
  • Your `useEffect`s do not reflect mounting/unmounting but updates; you'll need to add a dependency list to avoid the effects being executed on every update. – AKX Sep 20 '21 at 09:18

2 Answers2

2

As you can see, when a single input is updated, all the children are not re-rendered, they are first unmounted, then re-mounted. What a waste, all the input are already in the right state, only the sum needs to be updated. And imagine having hundreds of those inputs.

No, the logs you see from the useEffect don't represent a component mount/unmount. You can inspect the DOM and verify that only one input is updated even though all three components get rerendered.

If it was just a matter of re-rendering, I could look at memoization. But that wouldn't work because the callback is updated precisely because items change. No, my question is about the unmounting of all the children.

This is where you would use a functional state update to access the previous state and return the new state.

const callback = useCallback((item) => {
    setItems((prevItems) =>
      Object.assign([...prevItems], { [item.index]: item })
    );
}, []);

Now, you can use React.memo as the callback won't change. Here's the updated demo:

Edit inspiring-bash-utk30

As you can see only corresponding input logs are logged instead of all three when one of them is changed.

Ramesh Reddy
  • 10,159
  • 3
  • 17
  • 32
  • Thanks, along with Jonas' answer, I understand it all much better now. The trick with using the previous state in the callback was exactly what I was missing when I experimented with memoization. – ptrico Sep 20 '21 at 11:59
1

At first let's clarify some terminology:

  • A "remount" is when React deletes it's internal representation of the component, namely the children ("hidden DOM") and the states. A remount is also a rerender, as the effects are cleaned up and the newly mounted component is rendered
  • A "rerender" is when React calls the render method or for functional components the function itself again, compares the returned value to the children stored, and updates the children based on the result of the previous render

What you observe is not a "remount", it is a "rerender", as the useEffect(fn) calls the function passed on every rerender. To log on unmount, use useEffect(fn, []). As you used the key property correctly, the components are not remounted, just rerendered. This can also easily be observed in the App: the inputs are not getting reset (the state stays).

Now what you want to prevent is a rerender if the props do not change. This can be achieved by wrapping the component in a React.memo:

const ListItem = React.memo(function ListItem() {
  // ...
}); 

Note that usually rerendering and diffing the children is usually "fast enough", and by using React.memo you can introduce new bugs if the props are changed but the component is not updated (due to an incorrect areEqual second argument). So use React.memo rather conservatively, if you have performance problems.

Jonas Wilms
  • 132,000
  • 20
  • 149
  • 151
  • Hi Jonas, can you explain why my useEffect lines don't capture an unmount. Am I doing a useEffect cleanup incorrectly? The [React hooks docs](https://reactjs.org/docs/hooks-effect.html#example-using-hooks-1) say "React performs the cleanup when the component unmounts". – ptrico Sep 20 '21 at 11:14
  • 1
    @ptrico and the next sentence says "This is why React also cleans up effects from the previous render before running the effects next time" – Jonas Wilms Sep 20 '21 at 11:26
  • Thanks for prompting me to read the whole thing, I have a better understanding now. I've been able to capture mounting (and no unmounting ;-) with `useEffectLayout`. – ptrico Sep 20 '21 at 11:53
  • For memoization to work in my case, the callback had to not change and that's where @Ramesh Reddy's answer comes in. – ptrico Sep 20 '21 at 11:54