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

4

14

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?

Mccoy answered 11/10, 2022 at 20:54 Comment(1)
Related: #74215738Katharyn
M
11

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.

Muriah answered 12/10, 2022 at 2:56 Comment(6)
That's just the perfect explanation for this. Thank you so much!Mccoy
My question is how is there nothing to diff against? When I set a breakpoint at the return statement, I can see the value of state is 1, on the second render. It's odd that React isn't able to stop the infinite loop on the next rerender when state is the exact same as the value passed to setState. React Docs: "If the new value you provide is identical to the current state, ... React will skip re-rendering the component and its children. ...Although in some cases React may still need to call your component before skipping the children, it shouldn’t affect your code."Manx
@Manx That's if you are correctly enqueueing the state update as an intentional side-effect. Calling setState(1) in the render will just always trigger another rerender, e.g. create the render looping.Muriah
@DrewReese setState in a useEffect with no dependency array still causes infinite rerenders: const [state, setState] = useState('same'); useEffect(() => { setState('same') }) codepen.io/stan-stan/pen/KKEbZBv/…Manx
@Manx Ah, I think I see. Clearly I didn't test this code, shame on me. I'll just remove the blurb about using useEffect sans the dependency array.Muriah
@DrewReese I'm ashamed to admit that after some research, the CodePen I was using was using React and ReactDOM 16.7.0-alpha.2, which was an alpha version of hooks. That's why it was infinitely rerendering without a dependency array in the useEffect. 😭Manx
M
0

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>
Manx answered 15/2 at 17:14 Comment(0)
G
0

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.

Goliath answered 15/2 at 17:49 Comment(0)
X
-1

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?

Xavler answered 11/10, 2022 at 21:5 Comment(1)
That's how the hook works when there was a change in the state. Before React triggers re-rendering - it is always comparing the previous value of the state to the new one, and if it is the same - React will not re-render. If it re-render anyway then what's the point of this comparison and how the useEffect works?Mccoy

© 2022 - 2024 — McMap. All rights reserved.