Search code examples
javascriptreactjssettimeoutuse-effect

cannot update parent value all together in React hooks useEffect with setTimeout update


I have two components, parentComponent and ChildrenComponent. When I click the button, it will render handleClick method to update the childrenValue in ChildrenComponent. Inside ChildrenComponent, I use useEffect to watch the childrenValue change.

When I have a timer inside useEffect, insead of rerender once on the ParentComponent, why it's render everytime the parent component value change?

ParentComponent:

import React, { useState } from "react";
import { ChildrenComponent } from "./ChildrenComponent";

export const ParentComponent = () => {
  const [test1, setTest1] = useState(false);
  const [test2, setTest2] = useState(false);
  const [test3, setTest3] = useState(false);

  console.log("ParentComponent render", test1, test2, test3);

  const handleChangeParentValue = () => {
    setTest1(true);
    setTest2(true);
    setTest3(true);
  };

  return (
    <>
      <ChildrenComponent handleChangeParentValue={handleChangeParentValue} />
    </>
  );
};

ChildrenComponent:

import React, { useState, useEffect } from "react";

export const ChildrenComponent = (props) => {
  const { handleChangeParentValue } = props;
  const [childrenValue, setChildrenValue] = useState(false);

  useEffect(() => {
    if (childrenValue) {
      // handleChangeParentValue(); 
      // if I use handleChangeParentValue() instead of a timer, 
      // the parent component only render once with three true value: 
      // ParentComponent render true true true
      const timerSuccess = setTimeout(() => {
        handleChangeParentValue();
      }, 1000);
      return () => {
        clearTimeout(timerSuccess);
      };
    }
  }, [childrenValue]);

  const handleClick = () => {
    setChildrenValue((prev) => !prev);
  };
  return (
    <>
      <div>ChildrenComponent</div>
      <button onClick={handleClick}>CLick me</button>
    </>
  );
};

When I use a timer, the parent will render everytime the setTest run:

ParentComponent render true false false
ParentComponent render true true false
ParentComponent render true true true

Why is this happening? and if I want to use the timer on the child component for the value delay update but still want the parent component render once or update the parent value all together rather than separately, how should I do?


Solution

  • Update (July 15, 2021)

    From Automatic batching for fewer renders in React 18:

    Starting in React 18 with createRoot, all updates will be automatically batched, no matter where they originate from.

    This means that updates inside of timeouts, promises, native event handlers or any other event will batch the same way as updates inside of React events.


    You are preventing the state updates from being batched together by calling the handleChangeParentValue function asynchronously.

    When multiple state setter functions are called asynchronously, they are NOT batched together. This means that the parent component will re-render for each call to the state setter function.

    You have three calls to the state setter functions, so the parent component will re-render for each call, i.e. 3 times.

    Calling handleChangeParentValue synchronously leads to the multiple state updates being batched together, leading to only one re-render of the parent component.

    Following demo shows this behavior in action.

    function App() {
      const [state1, setState1] = React.useState({});
      const [state2, setState2] = React.useState({});
      const [state3, setState3] = React.useState({});
    
      console.log("App component rendered");
      
      const asyncStateUpdate = () => {
        console.log("triggering state updates asynchronously not batched");
        
        setTimeout(() => {
          setState1({});
          setState2({});
          setState3({});
        }, 1000);
      };
      
      const syncStateUpdate = () => {
         console.log("triggering state updates synchronously - batched");
         setState1({});
         setState2({});
         setState3({});
      };
      
      return (
        <div>
          <button onClick={asyncStateUpdate}>Trigger State Update Asynchronously</button>
          <button onClick={syncStateUpdate}>Trigger State Update Synchronously</button>
        </div>
      );
    }
    
    ReactDOM.render(<App/>, document.getElementById("root"));
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
    <div id="root"></div>

    Note: State update itself is asynchronous but you have to trigger the state update synchronously for multiple state updates to be batched together.

    Solution

    You could combine the three separate states into one state. As a result you will only have to call one state setter function. This way the parent component will only re-render once because there's only one state setter function call.

    Change the following

    const [test1, setTest1] = useState(false);
    const [test2, setTest2] = useState(false);
    const [test3, setTest3] = useState(false);
    

    to

    const [state, setState] = useState({
      test1: false,
      test2: false,
      test3: false
    });
    

    and replace the three state setter functions calls in handleChangeParentValue function with one state setter function call:

    const handleChangeParentValue = () => {
       setState({
          test1: true,
          test2: true,
          test3: true
       });
    };