2

I played around with react hooks, and I came across an issue that I do not understand.

The code is here https://codesandbox.io/embed/hnrch-hooks-issue-dfk0t The example has 2 simple components:

App Component

const App = () => {
  const [num, setNum] = useState(0);
  const [str, setStr] = useState();

  const inc = () => {
    setNum(num + 1);
    setStr(num >= 5 ? ">= 5" : "< 5");
    console.log(num);
  };

  const button = <button onClick={inc}>++</button>;

  console.log("parent rerender", num);

  return (
    <div className="App">
      <h1>App</h1>
      <Child str={str}>{button}</Child>
    </div>
  );
};

Child Component

const Child = React.memo(
  ({ str, children }) => {
    console.log("child rerender");
    return (
      <div>
        <>
          <h2>Functional Child</h2>
          <p>{str}</p>
          {children}
        </>
      </div>
    );
  }
  (prev, props) => {
    return prev.str === props.str;
  }
);

So I have the child wrapped in a React.memo, and it should only rerender if str is different. But is also has children, and it get's passed a button which is incrementing a counter inside the parent (App).

The issue is: The counter will stop incrementing after it is set to 1. Can someone explain this issue to me and what I need to understand to avoid those bugs?

3 Answers 3

3

It's a "closure issue".

This is the first render time for App, the inc function has been created the first time: (let's call it inc#1)

const inc = () => {
  setNum(num + 1);
  setStr(num >= 5 ? ">= 5" : "< 5");
  console.log(num);
};

In the inc#1 scope, num is currently 0. The function is then passed to button which is then passed to Child.

All good so far. Now you press the button, inc#1 is invoked, which mean that

setNum(num + 1);

where num === 0 happen. App is re-rendered, but Child is not. The condition is if prev.str === props.str then we don't render again Child.

We are in the second render of App now, but Child still own the inc#1 instance, where num is 0.

You see where the problem is now? You will still invoke that function, but inc is now stale.

You have multiple ways to solve the issue. You could make sure that Child has always the updated props.

Or you could pass a callback to setState to fetch the current value ( instead of the stale one that live in the scope of the closure ). This is also an option:

const inc = () => {
  setNum((currentNum) => currentNum + 1);
};

React.useEffect(() => {
  setStr(num >= 5 ? ">= 5" : "< 5");
}, [num])
Sign up to request clarification or add additional context in comments.

Comments

2

A couple of things here.

  1. If you are modifying a state and its new value depends on the previous value of the state, use setState's functional form:
 setNum(num => num + 1);   
  1. setState is async, so when you try to setStr, the num value is not updated yet. Even more, in your particular case inc is closing over (i.e. creates a closure) num value from the state, so inside that function it'll always have its initial value - 0. To fix this you need to use the Effect hook to update the string when num changes:
useEffect(() => {
    setStr(num >= 5 ? ">= 5" : "< 5");
  }, [num]) // Track the 'num' var here

Comments

1

One way to fix the bug was changing inc function to this:

const inc = () => {
  setNum(n => {
    const newNum = n + 1;
    setStr(newNum >= 5 ? ">= 5" : "< 5");
    return newNum;
  });
};

Note that setState is now passed a callback function, which receives the old value and returns the new one. That way, the "closure" issue was resolved.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.