Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React useState setters causing re-render

I'm wondering if it's expected behavior for the setter in: const [state, setState] = useState(0) to trigger component re-renders. So if I pass the setter to a component as a prop, is it supposed to trigger a re-render, and if so, why? Is there any way to avoid this?

I create a very simple sandbox to demonstrate the behavior: https://codesandbox.io/s/bold-maxwell-qj5mi

Here we can see when viewing the console.logs that the button component gets re-rendered with each click, while the incrementCounter function passed into it isn't changing. What gives?

like image 684
CaptainStiggz Avatar asked Feb 25 '20 01:02

CaptainStiggz


2 Answers

If you memoize the button, you do not experience this behavior.

Specifically, this:

const Button = memo(({ incrementCounter }) => {
  const renderCount = useRef(1);
  console.log("button rendered: ", renderCount.current);
  renderCount.current++;
  return <button onClick={incrementCounter}>Increment</button>;
});

CodeSandbox Mirror:

Edit delicate-http-b6sym


Update

If you wanted something from the docs the first sentence will tell you why this is happening. I know thats for setState but the same concept follows the useState hook, but the docs for that kind of suck. You can check out this part of the docs, specifically where it says "Line 9:"...

Remember, when state changes in X component, X component gets re-rendered, along with all of its children. I know React tells you to "lift state up", which is something I have never understood, because lifting state up causes a crap load of re-renders.

That is why the button re-renders.. because state is changing in its parent. The parent (<App />) has it's counter state changed, which triggers the re-render of the <App /> component, and its children, including <Button />.

In my opinion React is hard to control as far as state and re-renders, which Redux can assist with, but overall things like memo, useCallback, etc.. all feel like band-aids to me. If you put your state in the wrong component, you're going to have a bad time.

Wrapping the <Button /> component in memo basically says: if this component has a parent (in our case the <App />), and that parent re-renders, I want to look at all of our props, and if our props have not changed from what we received last time, don't re-render. Essentially, only re-render if our props change. That is why memo fixes this.. because the function we are using to handle the incrementCounter prop does not change - it remains constant.

I have added a few examples below demonstrating this.


Original Answer/Snippet:

const { memo, useState, useCallback, useEffect, useRef } = React;
const { render } = ReactDOM;

const App = () => {
  const [counter, setCounter] = useState(0);
  const incrementCounter = useCallback(() => {
    setCounter(c => c + 1);
  }, [setCounter]);

  useEffect(() => {
    console.log("increment changed!");
  }, [incrementCounter]);

  return (
    <div>
      <CountValue counter={counter} />
      <Button incrementCounter={incrementCounter} />
    </div>
  );
}

const CountValue = ({ counter }) => {
  return <div>Count value: {counter}</div>;
};

const Button = memo(({ incrementCounter }) => {
  const renderCount = useRef(1);
  console.log("button rendered: ", renderCount.current);
  renderCount.current++;
  return <button onClick={incrementCounter}>Increment</button>
});

render(<App />, document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>

SNIPPET #2:

This snippet shows how everything, not just the button, gets re-rendered.

const { useState, useEffect } = React;
const { render } = ReactDOM;

const App = () => {
  console.log("App rendered");
  const [counter, setCounter] = useState(0);
  const incrementCounter = () => setCounter(c => c + 1);

  useEffect(() => {
    console.log(" - Increment fired!");
    console.log();
  }, [incrementCounter]);

  return (
    <div>
      <CountValue counter={counter} />
      <Button incrementCounter={incrementCounter} />
      <p>Open console</p>
    </div>
  );
}

const CountValue = ({ counter }) => {
  console.log("CountValue rendered");
  return <div>Count value: {counter}</div>;
};

const Button = ({ incrementCounter }) => {
  console.log("Button rendered");
  return <button onClick={incrementCounter}>Increment</button>
};

render(<App />, document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>

SNIPPET #3:

This snippet shows how if you move state, etc.. into the <CountValue /> component, the <App /> component does not re-render..

const { useState, useEffect } = React;
const { render } = ReactDOM;

const App = () => {
  console.log("App rendered");
  
  return (
    <div>
      <CountValue />
      <p>Open console</p>
    </div>
  );
}

const CountValue = () => {
  console.log("CountValue rendered");
  
  const [counter, setCounter] = useState(0);
  const incrementCounter = () => setCounter(c => c + 1);
  
  return (
    <div>
      <div>Count value: {counter}</div>
      <Button incrementCounter={incrementCounter} />
    </div>
  );
};

const Button = ({ incrementCounter }) => {
  console.log("Button rendered");
  console.log();
  return <button onClick={incrementCounter}>Increment</button>
};

render(<App />, document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>

SNIPPET #4:

This snippet is more of a thought experiment which shows how to use render props.

const { useState, useEffect } = React;
const { render } = ReactDOM;

const App = () => {
  console.log("App rendered");

  return (
    <div>
      <CountValue present={({ increment, counter }) => {
        return (
          <div><Button incrementCounter={() => increment()} />
          <p>Counter Value: {counter}</p></div>
        )
      }} />
      <p>Open console</p>
    </div>
  );
}

const CountValue = ({ present }) => {
  const [counter, setCounter] = useState(0);
  
  const increment = () => {
    setCounter(c => c + 1);
  }
  
  console.log("CountValue rendered");
  return (
    <React.Fragment>
      {present({ increment, counter })}
    </React.Fragment>
  );
};

const Button = ({ incrementCounter }) => {
  console.log("Button rendered");
  return <button onClick={incrementCounter}>Increment</button>
};

render(<App />, document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>

SNIPPET #5:

It seems like this is what you're after.. This only re-renders the CountValue.. This is accomplished by passing the setCounter method, which is produced by useState, up to the parent, via an callback object.

This way the parent can manipulate state, without having to actually hold the state.

const { useState, useEffect } = React;
const { render } = ReactDOM;

const App = () => {
  console.log("App rendered");
  let increaseCount;

  return (
    <div>
      <CountValue callback={({increment}) => increaseCount = increment} />
      <Button incrementCounter={() => increaseCount()} />
      <p>Open console</p>
    </div>
  );
}

const CountValue = ({ callback }) => {
  console.log("CountValue rendered");
  const [counter, setCounter] = useState(0);
  
  callback && callback({
    increment: () => setCounter(c => c + 1)
  });
  
  return <p>Counter Value: {counter}</p>;
};

const Button = ({ incrementCounter }) => {
  console.log("Button rendered");
  return <button onClick={incrementCounter}>Increment</button>
};

render(<App />, document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>
like image 54
Matt Oestreich Avatar answered Sep 27 '22 23:09

Matt Oestreich


Yes, it is expected.

If you avoid the re-render you won't see the value that was set in the screen.

In case you want to create a value that you can change without triggering a re-render use useRef.

like image 41
Johnny Zabala Avatar answered Sep 27 '22 22:09

Johnny Zabala