1

I have a long process that updates the state. I want to show red background when it's running and blue when it's done.

const MapBuilder = (props) => {
    const [backgroundColor, setBackgroundColor] = useState(false);
    const [fancyResult, setFancyResult] = useState(null);

    console.log(`stop 1 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
    const veryHardWork = () => {
         setBackgroundColor("red");
         console.log(`stop 2 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
         for (let i = 0; i < 1000; i++) {
            for (let j = 0; j < 1000; j++) {
              console.log("So hard")
         }
    }

   setFancyResult("done")
   console.log(`stop 3 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
   setBackgroundColor("blue") 
   console.log(`stop 4 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
}

return (<div style={{background: backgroundColor}}>
   <button className="btn btn-primary" onClick={veryHardWork}></button>
</div>)
}

Here is an output of such run

stop 1 backgroundColor false fancyResult null
MapBuilder.js:13 stop 2 backgroundColor false fancyResult null
10000MapBuilder.js:16 So hard
MapBuilder.js:20 stop 3 backgroundColor false fancyResult null
MapBuilder.js:22 stop 4 backgroundColor false fancyResult null
MapBuilder.js:10 stop 1 backgroundColor blue fancyResult done

I understand from this that the state change only happens after the method veryHardWork is finished. In my real project, I actually want to show a spinner the question is how can I do it if the state is only changed at the end of the method.

I think some clarification needs to be added. In reality, I allow the user to choose a file after the user chooses the file it is loaded and some heavy processing is performed on files data while the processing is running I want to show a spinner no Asyn work involved.

Some of the answers sugested to use useEffect and moving it to a promise I tryied both but it did not help here is a different take on it which also did not work

const MapBuilder = (props) => {
  const [backgroundColor, setBackgroundColor] = useState(false);
  const [fancyResult, setFancyResult] = useState(null);
  const [startProcessing, setStartProcessing] = useState(null);

  useEffect(() => {
    let myFunc = async () => {
      if (startProcessing) {
        setBackgroundColor("red");
        await hardWork();
        setBackgroundColor("blue");
        setStartProcessing(false);
      }
    }

    myFunc();
  }, [startProcessing])

  const hardWork =  () => {
    return new Promise((resolve)=> {
      for (let i = 0; i < 500; i++) {
        for (let j = 0; j < 100; j++) {
          console.log("So hard")
        }
      }
      setFancyResult("sdsadsad")
      resolve("dfsdfds")
    })
  }

  return (<div style={{background: backgroundColor}}>
    <button className="btn btn-primary" onClick={() => setStartProcessing(true)}></button>
  </div>)
}


export default MapBuilder;
urag
  • 1,228
  • 9
  • 28
  • The task that you wish to perform on button click is it a synchronous task or an asynchronous task? – SSM May 26 '22 at 17:55
  • It is synchronous – urag May 26 '22 at 18:09
  • What kind of synchronous work is it? Can you *not* move it to the backend? Because it would effectively freeze the entire browser until that heavy task is done. – SSM May 26 '22 at 18:49

4 Answers4

0

You already control state of fancyResult,
or you can use showSpinner state for only reason to show spinner

You can use for long progress Promise [Link] And Async/Await [Link]

const veryHardWork = async () => {
    setBackgroundColor("red");
    const awaiting = await new Promise((resolve, reject) => {
        for (let i = 0; i < 1000; i++) {
            for (let j = 0; j < 1000; j++) {
                resolve(console.log("So hard"));
            }
        }
    })
    // After Finished Awating Hardwork
    setFancyResult("done");
    setBackgroundColor("blue") ;
}


  return (
    <div style={{background: fancyResult === 'done' ? 'blue': 'red'}}>
      <button className="btn btn-primary" onClick={veryHardWork}></button>
      { fancyResult === 'done' && 'Show Spinner' }
    </div>
  )
Wu Woo
  • 46
  • 3
0

The problem with the approach is that the heavy calculation is happening at the main loop with the same priority. The red color change will not ever cause any changes until all things at the event handler have been finished.

With Reach 18 you can make your heavy calculation to be with lower priority and let the UI changes happen with normal priority. You can make this happen with minor change on your code base:

const veryHardWork = () => {
  setBackgroundColor("red");

  // Set heavy calculation to happen with lower priority here...
  startTransition(() => {
    console.log(`stop 2 backgroundColor ${backgroundColor} fancyResult ${fancyResult}`)
    for (let i = 0; i < 1000; i++) {
      for (let j = 0; j < 1000; j++) {
        console.log("So hard")
      }
    }
    setFancyResult("done")
    setBackgroundColor("blue") 
  }
}
Ville Venäläinen
  • 2,444
  • 1
  • 15
  • 11
0

So I've made you a more real world example as the code you posted doesn't look like what you're actually wanting to achieve.

The scenario to my understanding is you want to preform some setup actions to get your state / data ready before showing it to the user.

cool, so first we will need some state to keep track of when we're ready to show content to the user lets call it isLoading. This will be a boolean that we can use to conditionally return either a loading spinner, or our content.

next we need some state to keep hold of our data, lets call this one content.

each state will be created from React.useState which can be imported with import { useState } from 'react';. We will then create variables in the following format:

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

nice, so now lets do somthing when the component mounts, for this we will use React.useEffect this hook can be used to tap into the lifecycle of a component.

inside our useEffect block we will preform our set up. In this case I'll say it's an async function that get some data from an API and then sets it to state.

lastly we will use our isLoading state to decide when we're ready to show the user something more interesting than a spinner.

All together we get something like this:

import { useState, useEffect } from 'react';

const MyComponent = () => {
  // create some state to manage when what is shown
  const [isLoading, setIsLoading] = useState(true);
  // create some state to manage content
  const [content, setContent] = useState(null);

  // when the component is mounted preform some setup actions
  useEffect(() => {
    const setup = async () => {
      // do some setup actions, like fetching from an API
      const result = await fetch('https://jsonplaceholder.typicode.com/todos/1')
        .then(r => r.json());

      // update our state that manages content
      setContent(result);
      // when we are happy everything is ready show the user the content
      setIsLoading(false);
    };
    // run our setup function
    setup();
  }, [ ]);


  // if we are not yet ready to show the user data, show a loading message
  if (isLoading) {
    return (
      <div>
        <p>spinner goes in this div</p>
      </div>
    )
  }

  // when we are ready to show the user data is will be shown in this return statement
  return (
    <div>
      <p>this div will show when the `isLoading` state is true and do something with the content state is wanted</p>
    </div>
  )
}

I believe you'll find this more useful than the example you provided

Shaded
  • 158
  • 1
  • 9
  • No, unfortunately, this will not work it's not an asynchronous API call. It's loading a file and then (loading is actually fast) making some heavy processing with it. UseEffect will not work in such a situation – urag May 26 '22 at 18:23
  • can you expand? because I still think this is along the right lines, you could await a promise that handles your processing and update the state to show once the promise resolves. As for useEffect, in the way I've used it above it simply ensures that the setup is only ran once. – Shaded May 26 '22 at 18:31
0

Try this:

import { useState, useEffect } from "react";
import { flushSync } from "react-dom";

const MapBuilder = (props) => {
  const [backgroundColor, setBackgroundColor] = useState(false);
  const [fancyResult, setFancyResult] = useState(null);

  // this will only be called on the first mount
  useEffect(() => {
    console.log(
      `backgroundColor ${backgroundColor} fancyResult ${fancyResult}`
    );
  }, [backgroundColor, fancyResult]);

  const veryHardWork = () => {
    setBackgroundColor("red");

    setTimeout(() => {
      for (let i = 0; i < 1000; i++) {
        for (let j = 0; j < 1000; j++) {
          for (let k = 0; k < 1000; k++) {
            // console.log("So hard");
          }
        }
      }

      setFancyResult("done");
      // flushSync(() => setFancyResult("done"));

      console.log(
        `inside closure - OUTDATED: backgroundColor ${backgroundColor} fancyResult ${fancyResult}`
      );

      setBackgroundColor("blue");
    }, 0);
  };

  return (
    <div style={{ background: backgroundColor }}>
      <button className="btn btn-primary" onClick={veryHardWork}>
        Work!
      </button>
    </div>
  );
};
export default MapBuilder;

CodeSandbox

Explanation:

Unblocking the UI thread

In order for the color to change to red, the UI thread must be freed up to

  1. make the state changes (set background color to red) and
  2. do the re-render.

One way to achieve this is by using setTimeout. This puts a function on a queue to be run later, allowing the UI thread to finish the above 2 tasks before tackling the actual work. You should note though, this doesn’t actually run your work on a new thread, so once the work starts getting done, the UI will be unresponsive until the work is done. Consider using a Web Worker to solve this in the future.

Logging the current state

The other thing to understand about React is that every time a re-render occurs, the entire function ('MapBuilder’ in this case) is re-run. This means that your ‘stop 1’ message will be displayed every re-render, and therefore every time the state changes.

Additionally, logging the state from within veryHardWork will log the state when the function was defined. This means that the value will be outdated, i.e. stale. This is because of a functional concept called closures. From Wikipedia “Unlike a plain function, a closure allows the function to access those captured variables through the closure's copies of their values or references, even when the function is invoked outside their scope.”

So how should we log the current state when it is changed? By using the useEffect hook. This function will be re-run whenever any of the dependencies change ([backgroundColor, fancyResult] in this case).

Console.log undefined behavior

Another thing to note is that many console.logs should not be used as the ‘work’. The rendering of the log will happen asynchronously, so ‘firing’ the logs will be much quicker than they will actually show up. This leads the observer watching the console to think that the ‘red’ stage has been skipped. Instead, we can just loop more times, or do some math in the loop, etc (which is closer to what your actual synchronous work will be anyway). In fact, console.log seems to be quite unpredictable, as noted here.

Automatic Batching

You might be wondering why “done” and “blue” show up as a single state update (i.e. stop 3 and 4 happen at the same time). This is because of automatic batching. As a performance optimization, react attempts to ‘batch’ state changes to prevent additional re-renders. To prevent this behavior, you can uncomment line 27 flushSync(() => setFancyResult("done”)). This is not necessary for this use-case, as the batching is appropriate here, but it’s helpful to understand what’s going on.

tripp
  • 128
  • 5
  • Unfortunately, this does not work the background color is indeed red at first but after a few seconds, it turns blue. What's strange is that the 3*For are still running but somehow it reaches setBackgroundColor("blue"); although they suppose to be iin the same thread – urag May 29 '22 at 07:17
  • @urag this is not accurate. Refer to the code sandbox, I have edited it to be keeping track of a count variable, incrementing it each iteration of the nested loop. It then displays below the work button. As you can see, the color turns to blue at the same moment that the number is shown. – tripp May 29 '22 at 22:50