Tehila
Tehila

Reputation: 1102

Updating state to the same value directly in the component body during render causes infinite loop

Let's say I have this simple dummy component:

const Component = () => {

  const [state, setState] = useState(1);

  setState(1);

  return <div>Component</div>
}

In this code, I update the state to the same value as before directly in the component body. But, this causes too many re-renders even if the value stayed the same.

And as I know, in React.useState, if a state value was updated to the same value as before - React won't re-render the component. So why is it happening here?

However, if I try to do something similar with useEffect and not directly in the component body:

const Component = () => {

  const [state, setState] = useState(1);

  useEffect(() => {
    setState(1);
  }, [state])

  return <div>Component</div>
}

This is not causing any infinite loop and goes exactly according to the rule that React won't re-render the component if the state stayed the same.

So my question is: Why is it causing an infinite loop when I do it directly in the component body and in the useEffect it doesn't?

Does anyone have some "behind the scenes" explanation for this?

Upvotes: 14

Views: 4597

Answers (4)

cyclingLinguist
cyclingLinguist

Reputation: 354

As support for Drew Reese's answer, here's a little playground that lets you see for yourself how React behaves when setState is directly in the component body versus inside a useEffect or conditional where it is only triggered after the first render completes.

As Drew Reese says, if setState(), even to the same primitive value, happens during the execution of the component body, React will get stuck in an infinite loop. Somehow it's not diffing anything during that first render, so it doesn't matter that setState() is set to the same value that it was initialized with.

Here is a gold mine of wisdom for this situation: https://github.com/facebook/react/issues/20817#issuecomment-778672150

Use the JSFiddle to be able to edit / commment / uncomment: https://jsfiddle.net/7b84xm9u/

    const {
  useState,
  useEffect,
  useRef
} = React;
const {createRoot} = ReactDOM


const Component = () => {
  const renderCountRef = useRef(0);
  const [same, setSame] = useState('same');
  const [count, setCount] = useState(0);

  renderCountRef.current++;
  console.log(renderCountRef.current)
  document.getElementById('render-count').innerHTML = renderCountRef.current

  // Doesn't cause any rerenders
  useEffect(() => {
    setSame('same');
  });

  // 👇 Will cause an infinite rerender loop. (Uncomment to see the error, in dev console)

 // /* <= uncomment */ setSame('same'); 


  // Use the debugger to see that nothing ever actually has a chance to get committed to the DOM (the React area remains blank)
  // when setState is called in the body of the component

  // /* <= uncomment */ debugger 

  
  // A setState that happens while the component body is being executed
  // will cause another execution of the component body.
  // Hence the below will cause an infinite rerender, after first render.
  if (renderCountRef.current > 1) {
     setSame('same'); 
  }

   return (
    <div>
      <h2> React </h2>
      I'm not erroring! 😁 <br />
      I've rendered {renderCountRef.current} times. <br />{" "}
      <br />
      {/** Doesn't cause a rerender **/}{" "}
      <button
        onClick={() => {
          setSame("same")
        }}
      >
        Set state to same primitive value{" "}
      </button>{" "}
      <br /> <br />
      <button onClick={() => {setCount((prev) => prev + 1)}}>Click me to crash! Count: {count}</button>
    </div>
  )
};


createRoot(document.getElementById('root')).render(<Component />)
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div style="display: flex; font-family: sans-serif; gap: 20px; padding-bottom: 20px;">
  <div style="border: solid 1px blue;" id="root"></div>
  <div style="border: 1px dashed green">
    <h2>Outside React</h2>
    Render count: <span id="render-count">0</span>
  </div>

Upvotes: 0

JakeSidSmith
JakeSidSmith

Reputation: 933

I think the simplest way to explain this is...

In your first example setState is called every time the component re-renders, and will itself cause the component to re-render, hence the infinite loop.

I should note here that a component re-rendering does not necessarily mean there will be updates to the DOM, thanks to React's virtual DOM.

In your second example the useEffect which triggers the setState call is only called when its dependencies (second argument) change, and since its only dependency is the state it will not be triggered again if the state has not changed.

You should never change any state during the render of a component. These can be handled in effects, which basically exist to prevent your infinite loop scenario.

Upvotes: 0

Drew Reese
Drew Reese

Reputation: 202605

TL;DR

The first example is an unintentional side-effect and will trigger rerenders unconditionally while the second is an intentional side-effect and allows the React component lifecycle to function as expected.

Answer

I think you are conflating the "Render phase" of the component lifecycle when React invokes the component's render method to compute the diff for the next render cycle with what we commonly refer to as the "render cycle" during the "Commit phase" when React has updated the DOM.

See the component lifecycle diagram:

enter image description here

Note that in React function components that the entire function body is the "render" method, the function's return value is what we want flushed, or committed, to the DOM. As we all should know by now, the "render" method of a React component is to be considered a pure function without side-effects. In other words, the rendered result is a pure function of state and props.

In the first example the enqueued state update is an unintentional side-effect that is invoked outside the normal component lifecycle (i.e. mount, update, unmount).

const Component = () => {
  const [state, setState] = useState(1);

  setState(1); // <-- unintentional side-effect

  return <div>Component</div>;
};

It's triggering a rerender during the "Render phase". The React component never got a chance to complete a render cycle so there's nothing to "diff" against or bail out of, thus the render loop occurs.

The other example the enqueued state update is an intentional side-effect. The useEffect hook runs at the end of the render cycle after the next UI change is flushed, or committed, to the DOM.

const Component = () => {
  const [state, setState] = useState(1);

  useEffect(() => {
    setState(1); // <-- intentional side-effect
  }, [state]);

  return <div>Component</div>;
}

The useEffect hook is roughly the function component equivalent to the class component's componentDidMount, componentDidUpdate, and componentWillUnmount lifecycle methods. It is guaranteed to run at least once when the component mounts regardless of dependencies. The effect will run once and enqueue a state update. React will "see" that the enqueued value is the same as the current state value and won't trigger a rerender.

The takeaway here is to not code unintentional and unexpected side-effects into your React components as this results in and/or leads to buggy code.

Upvotes: 12

Christian
Christian

Reputation: 169

When invoking setState(1) you also trigger a re-render since that is inherently how hooks work. Here's a great explanation of the underlying mechanics:

How does React.useState triggers re-render?

Upvotes: -1

Related Questions