Search code examples
javascriptreactjsreact-hookstsx

React - Set state work only in async operation


I'm using React (with TypeScript), and I found out very strange behavior. In the method "handleAnswer" I'm changing the variable "isReady" and also printing the value using the useEffect hook. but the change only takes place if I put a timer there, When I took the timer out the variable stopped changing, and I stopped seeing the output in the console. What am I doing wrong?

export const App = () => {

  const [isReady, setIsReady] = useState(true);
  const [counter, setCounter] = useState(0);


  useEffect(() => { console.log(isReady) },[isReady]);
  
  
  const handleAnswer = async (isLiked: boolean) => {
    setIsReady(false);

    // WHEN I REMOVE THIS LINE, IT DOESN'T WORK
    await new Promise(resolve => setTimeout(resolve, 10));

    setCounter(counter + 1)
    setIsReady(true);
  }


  return (
    <div className="App" >

    { isReady ? <div className="main-content"></div> :<CircularProgress /> }

    <Button variant="contained" onClick={() => handleAnswer(true)} startIcon={<ThumbUpIcon />}>
      click
    </Button>
   </div>
  );

}

export default App;


Solution

  • State updates are queued and processed as a batch after the code that queued them has returned. So without the promise, your handleAnswer code does this:

    1. Queues a state update changing isReady to false.
    2. Queues a state update adding to counter.
    3. Queues a state update changing isReady to true.

    React does those three updates once your code has returned, so they only trigger a single re-render, and during that re-render isReady has the same value it had the last time the dependencies on your useEffect were checked, so the useEffect callback doesn't run.

    With the promise, your code does this:

    1. Queues a state update changing isReady to false.
    2. Yields back waiting for a promise to settle.
    3. Once the promise settles:
      1. Queues a state update adding to counter.
      2. Queues a state update changing isReady to true.

    Between #2 and #3 on that list, React has a chance to process state changes, and so it does — triggering a re-render when isReady is false, which triggers your useEffect callback because isReady has a different value than the last time the dependencies were checked. Then later, after the promise settles, your code queues a couple of other state changes, which trigger another render, triggering your useEffect callback again.

    The key points are:

    • State updates are not immediate
    • State updates are batched
    • Even if the state changed and then changed back, if there was no render between those changes, the useEffect callback triggered by a the value being different won't run, because the value isn't different by the time it's checked

    In a comment you've asked:

    So I can achieve this behavior without using promise? I need the isReady flag to control a scroll loader until the function is done handling some data synchronously.

    If the processing is synchronous, it will tie up the main thread that handles UI updates, and your loading indicator may well not be animated (it definitely won't be if the animation is done with JavaScript; if it's an animated GIF or similar, whether it's done depends on the browser and whether all frames are already loaded; if it's a CSS animation, a quick test with Chrome and Firefox suggests that they, at least, will keep animating when the processing is happening, but no guarantees).

    If you can avoid synchronous processing on the main thread, I would. Consider sending the work to a worker thread, for instance.

    To do it, though, you'll need to change isReady, then wait to start the synchronous processing until after the result of that change has been rendered by waiting for a useEffect callback.

    Here's an example using a CSS spinner I found here.

    const { useState, useEffect } = React;
    
    const Example = () => {
        const [isReady, setIsReady] = useState(true);
    
        // On click, set `isReady` to false
        const startProcessing = () => {
            setIsReady(false);
        };
    
        // When `isReady` becomes false, start "processing".
        // (You may want to have an instance variable [via a ref]
        // to tell you whether to actually do processing, and if
        // so what to process.)
        useEffect(() => {
            if (!isReady) {
                // Start "processing"
                const done = Date.now() + 3000;
                while (Date.now() < done) {
                    // Busy wait -- NEVER DO THIS IN REAL-WORLD CODE
                }
                // Done "processing"
                setIsReady(true);
            }
        }, [isReady]);
    
        return <div>
            <input type="button" onClick={startProcessing} value="Start Processing (Takes 3 Seconds)" />
            <div>isReady = {String(isReady)}</div>
            {isReady ? null : <div className="spinner-loader" />}
        </div>;
    };
    
    ReactDOM.render(<Example />, document.getElementById("root"));
    /*
    Credit: jlong  @github
    Source: https://github.com/jlong/css-spinners
    */
    @-moz-keyframes spinner-loader {
      0% {
        -moz-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -moz-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }
    @-webkit-keyframes spinner-loader {
      0% {
        -webkit-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -webkit-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }
    @keyframes spinner-loader {
      0% {
        -moz-transform: rotate(0deg);
        -ms-transform: rotate(0deg);
        -webkit-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -moz-transform: rotate(360deg);
        -ms-transform: rotate(360deg);
        -webkit-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }
    /* :not(:required) hides this rule from IE9 and below */
    .spinner-loader:not(:required) {
      -moz-animation: spinner-loader 1500ms infinite linear;
      -webkit-animation: spinner-loader 1500ms infinite linear;
      animation: spinner-loader 1500ms infinite linear;
      -moz-border-radius: 0.5em;
      -webkit-border-radius: 0.5em;
      border-radius: 0.5em;
      -moz-box-shadow: rgba(0, 0, 51, 0.3) 1.5em 0 0 0, rgba(0, 0, 51, 0.3) 1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) 0 1.5em 0 0, rgba(0, 0, 51, 0.3) -1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) -1.5em 0 0 0, rgba(0, 0, 51, 0.3) -1.1em -1.1em 0 0, rgba(0, 0, 51, 0.3) 0 -1.5em 0 0, rgba(0, 0, 51, 0.3) 1.1em -1.1em 0 0;
      -webkit-box-shadow: rgba(0, 0, 51, 0.3) 1.5em 0 0 0, rgba(0, 0, 51, 0.3) 1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) 0 1.5em 0 0, rgba(0, 0, 51, 0.3) -1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) -1.5em 0 0 0, rgba(0, 0, 51, 0.3) -1.1em -1.1em 0 0, rgba(0, 0, 51, 0.3) 0 -1.5em 0 0, rgba(0, 0, 51, 0.3) 1.1em -1.1em 0 0;
      box-shadow: rgba(0, 0, 51, 0.3) 1.5em 0 0 0, rgba(0, 0, 51, 0.3) 1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) 0 1.5em 0 0, rgba(0, 0, 51, 0.3) -1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) -1.5em 0 0 0, rgba(0, 0, 51, 0.3) -1.1em -1.1em 0 0, rgba(0, 0, 51, 0.3) 0 -1.5em 0 0, rgba(0, 0, 51, 0.3) 1.1em -1.1em 0 0;
      display: inline-block;
      font-size: 10px;
      width: 1em;
      height: 1em;
      margin: 1.5em;
      overflow: hidden;
      text-indent: 100%;
    }
    <div id="root"></div>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>