DEV Community

Cover image for Updating React State Inside Loops
Adam Nathaniel Davis
Adam Nathaniel Davis

Posted on • Edited on

Updating React State Inside Loops

I recently ran into a very nasty little conundrum with React. I found a use-case where I felt it was necessary to update React state variables inside a loop. I initially had no idea how vexing it would be.


Image description

State Management Under-The-Hood

Before I dive into this problem, there are a few basic facts that you need to understand about how React manages state variables. This should be second nature to all of you salty old React vets out there. But it's important to establish these ground rules before we dive in.

React state is asynchronous

When you update a React variable, the subsequent mutation occurs asynchronously. You can prove it out with an example like this:



export const MyComponent = () => {
  const [counter, setCounter] = useState(0);

  const increment = () => {
    setCounter(counter + 1);
    console.log('counter', counter);
  }

  return <>
    <div>
      Counter = {counter}
    </div>
    <button onClick={increment}>
      Increment
    </button>
  </>
}


Enter fullscreen mode Exit fullscreen mode

From a UX perspective, the example above works just fine. Every time you click Increment, the counter value updates. However, the console.log() output will always be one update behind. This happens because the update occurs asynchronously. And when the code hits the console.log(), the value has not yet been updated.

React batches state updates

React does a lot of work behind the scenes to optimize performance. Normally, this optimization doesn't really phase us. Because the typical flow is:

  1. A user performs some action (like clicking a button).
  2. The state variable is updated.
  3. React's reconciliation process is triggered.
  4. This process realizes that there's now a diff between the virtual DOM and the actual DOM.
  5. React updates the actual DOM to reflect the changes in the virtual DOM.

In the vast majority of circumstances this all happens so fast as to appear, to the naked eye, as instantaneous. But the good folks who built React also put in some safeguards to protect against those instances where a developer tries to fire off a rapid series of state updates.

Of course, this optimization is, by and large, a very good thing. But it can be baffling when you feel that those successive updates are truly necessary. Specifically, React goes out of its way to ensure that all state updates that are triggered within a loop will not actually be rendered until the loop has finished running.


Image description

A Case Study For State Updates Inside A Loop

My website - https://paintmap.studio - does image/color manipulation. Although the functionality works precisely as designed, this does present a few challenges:

  1. Images have the potential to be large. I could introduce some artificial limits on the size of the image file (either in raw kilobytes or in height/width dimensions), but doing so would undermine much of the utility of the application.

  2. The images are not being uploaded and processed on a server. It's a single page application with no backend component. It runs entirely in the browser. So I can't, for example, upload an image, commence some intensive processing, and then promise to email the user a link to the finished product when the process is complete.

  3. The amount of time it takes to process an image is largely dependent upon the processing options that are chosen by the user. For example, processing an image with the RGB color space is much faster than using Delta-E 2000. Dithering an image takes more time than if dithering is avoided. So in theory, I could introduce some artificial limits on the number of options available to the user. But again, this would undermine much of the utility of the application itself.

The simple fact is that sometimes I want to transform an image, of a certain size, and with a certain number of processor-intensive options, that's gonna take a little time to complete. When that happens, I don't want the user wondering if the application has crashed. I want them to understand that everything is chugging along as-planned and their newly-processed image will be completed soon.


Image description

The Progress Bar

One of the most time-tested ways to let the user know that processing is continuing as-planned is to give them some kind of progress bar. Ideally, that progress bar updates in real-time as the code works through its machinations.

Of course, this is nothing new at all. Applications have been providing this type of user feedback for decades. But when I tried to implement this in React with Material UI's <CircularProgress> component, I ran into some really nasty headaches.

Because I don't want to confuse the matter by trying to drop you right into the middle of Paintmap Studio's code, I've instead created a simplified demo in CodeSandbox to illustrate the problem. You can view that demo here:

https://codesandbox.io/s/update-react-state-in-a-loop-04b9ek


Image description

The Problem - Illustrated

In the Code Sandbox demo, I've created an artificially-slow process to illustrate the issue. When you load this demo, it gives you a single button on the screen titled START SLOW PROCESS. The idea is that, once you click that button, a somewhat lengthy process will be triggered.

But we don't want the user wondering whether the site's crashed. So once the button is clicked, we need to have an interstitial overlay shown on screen. The overlay let's the user know that the process is underway, and ideally, gives them a dynamic progress indicator that lets them know how far the process is from being completed.

We'll start the illustration with App.js:



// App.js
export const AppState = createContext({});

export const App = () => {
  const [progress, setProgress] = useState(0);
  const [showProcessing, setShowProcessing] = useState(false);

  return (
    <>
      <AppState.Provider
        value={{
          progress,
          setProgress,
          setShowProcessing,
          showProcessing
        }}
      >
        <UI />
      </AppState.Provider>
    </>
  );
};


Enter fullscreen mode Exit fullscreen mode

App.js is mostly just a wrapper for the UX. However, I'm using the Context API to establish some state variables that will be useful in the downstream code. progress represents the completion percentage of our SLOW PROCESS. showProcessing determines whether the progress interstitial should be shown.

Now let's take a look at UI.js:



// UI.js
export const UI = () => {
  const { progress, showProcessing } = useContext(AppState);
  const { runSlowProcess } = useSlowProcess();

  const handleSlowProcessButton = () => runSlowProcess();

  return (
    <>
      <Backdrop
        sx={{
          color: "#fff",
          zIndex: (theme) => theme.zIndex.drawer + 1
        }}
        open={showProcessing}
      >
        <div className={"textAlignCenter"}>
          <Box
            sx={{
              display: "inline-flex",
              position: "relative"
            }}
          >
            <CircularProgress
              color={"success"}
              value={progress}
              variant={"determinate"}
            />
            <Box
              sx={{
                alignItems: "center",
                bottom: 0,
                display: "flex",
                justifyContent: "center",
                left: 0,
                position: "absolute",
                right: 0,
                top: 0
              }}
            >
              <Typography 
                color={"white"} 
                component={"div"} 
                variant={"caption"}
              >
                {`${progress}%`}
              </Typography>
            </Box>
          </Box>
          <br />
          Slow Process Running...
        </div>
      </Backdrop>
      <Button
        onClick={handleSlowProcessButton}
        size={"small"}
        variant={"contained"}
      >
        Start Slow Process
      </Button>
    </>
  );
};


Enter fullscreen mode Exit fullscreen mode

First, the named components in the JSX, like <Backdrop>, <Box>, <CircularProgress>, <Typography>, and <Button> come from Material UI.

Second, the component leverages the AppState context that was established in App.js. Specifically, we'll be using showProcessing to toggle the visibility of the interstitial layer. And we'll be using progress to show the user how far the SLOW PROCESS is from completion.

Finally, this component also imports a custom Hook. That Hook is called useSlowProcess(). Let's take a look at that:



// useSlowProcess.js
export const useSlowProcess = () => {
  const { setProgress, setShowProcessing } = useContext(AppState);

  const runSlowProcess = async () => {
    const startTime = Math.round(Date.now() / 1000);
    setProgress(0);
    setShowProcessing(true);
    for (let i = 1; i < 101; i++) {
      for (let j = 1; j < 1001; j++) {
        for (let k = 1; k < 1001; k++) {
          for (let l = 1; l < 1001; l++) {
            // do the stuffs
          }
        }
      }
      console.log(i);
      setProgress(i);
    }
    setShowProcessing(false);
    console.log(
      "Elapsed time:",
      Math.round(Date.now() / 1000) - startTime,
      "seconds"
    );
  };

  return {
    runSlowProcess
  };
};


Enter fullscreen mode Exit fullscreen mode

useSlowProcess() returns a single function: runSlowProcess(). runSlowProcess() does the following:

  1. It establishes a startTime so we can calculate just how long the SLOW PROCESS actually takes. (After playing with this repeatedly on Code Sandbox, I can tell you that it takes ~27 seconds to complete.)

  2. It ensures that our progress variable starts at 0.

  3. It then sets showProcessing to true so the user will see the in-progress interstitial.

  4. It then launches into the SLOW PROCESS.

  5. The outer loop runs for 100 iterations. This means that every completion of the outer loop essentially means that we've completed 1% of the overall process.

  6. After each percentage is completed, we console.log() the current progress and we update the progress variable. Remember, that variable will be used to show the user how close we are to completion.

  7. When the process is complete, we set showProcessing back to false. This should remove the in-progress interstitial.

  8. Finally, we console.log() the total number of seconds that it took to complete the SLOW PROCESS.

So what happens when we run this code??? Well... it's very disappointing.

When the user clicks the START SLOW PROCESS button, there is no interstitial shown on screen. This also means that the user sees no <CircularProgress> bar, with no constantly-updated completion percentage.

But why does this happen?


Image description

Batching Headaches

This is where we run head-first into the problems with React's batch updating of state variables. React sees that we're setting showProcessing to true near the beginning of runSlowProcess() but we're also setting it to false near the end of the process. So the batched result is that it simply sets showProcessing to false. Of course, it was already false when we began the process. So setting the previous value of false to... false results in the user never even seeing the in-progress interstitial.

Even if we solved that problem, the user would never see any of the percent updates to the progress variable displayed inside the <CircularProgress> component. Why? Because React sees that the state variable progress is being updated repeatedly through the outer for loop. Thus, it batches all of those updates into a single value. Of course, this completely undermines the whole purpose of having a progress indicator in the first place.


Image description

Frustrating (Non)Help

Remember, the <CircularProgress> component comes from Material UI. And Material UI has a ton of helpful documentation that's supposed to show you how to use their components. So my first step was to go back to that documentation for help. But... it was of no help whatsoever.

Here's the code sample that Material UI gives you to illustrate how you can update the progress value in the <CircularProgress> component:



export default function CircularDeterminate() {
  const [progress, setProgress] = React.useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setProgress((prevProgress) => (prevProgress >= 100 ? 0 : prevProgress + 10));
    }, 800);

    return () => {
      clearInterval(timer);
    };
  }, []);

  return (
    <CircularProgress variant="determinate" value={progress} />
  );
}


Enter fullscreen mode Exit fullscreen mode

To be clear, Material UI's code sample "works". I mean... it does dynamically update the progress value inside the <CircularProgress> component. But this is one of the few times that I found their documentation to be borderline useless.

You see, they're using useEffect(), in conjunction with setInterval(), to blindly update the progress value based on nothing more than a predetermined delay of 800 milliseconds. While that works fine for displaying a rote example of how the component renders, it does nothing to tell us how we can tie the progress value to an actual, meaningful calculation based upon the true progress of the process.

Faced with this very useless example, I then did what any lifelong developer would do: I started googling. But almost all of the posts I found on places like Stack Overflow were similarly useless.

The issue you encounter when you google this problem is that nearly every developer has the exact same "answer":

Don't update React state variables in a loop.


While I understand that there are many scenarios where you do want to avoid repeatedly updating React state inside a loop, the simple fact is that this use case is the quintessential use case where you absolutely should be updating React state inside a loop. Because the progress value should, ideally, be calculated based upon the actual progress of the algorithm - NOT based upon some mindless setInterval() delay.

I actually encounter this sort of non-help all the time. You're trying to solve some kind of sticky programming problem. But rather than providing any meaningful help, the mouth-breathers simply reply that you shouldn't be doing this at all.

It's like seeing someone's post about how they can't figure out how to cook a proper souffle without it collapsing and becoming a mess - and some smartass troll in the cooking forum responds by saying:

You shouldn't be cooking souffles anyway. Just make an omelette.


Wow. That's sooooo helpful. Thankfully, I did eventually solve the problem...


Image description

Promises To The Rescue

The key to solving this one is that you have to introduce a delay. If you look at what's happening in Material UI's example, they're invoking a delay with setInterval(). But the real reason why their example "works" is because the setInterval() is embedded within useEffect(). This creates a kind of feedback loop where the state variable is updated inside useEffect(), then the reconciliation process updates the DOM, which triggers another call to useEffect(), which then updates the variable again, and so on and so on...

Of course, in my example, I'm trying to track the progress of a function inside a Hook. So it's not terribly useful to rely upon useEffect().

But there's another way to invoke a delay without using useTimeout() or useInterval(). You can use a promise. Promises (and their associated async/await convention) basically knock React out of its batch update mentality.

The updated useSlowProcess() code looks like this:



// useSlowProcess.js
export const useSlowProcess = () => {
  const { setProgress, setShowProcessing } = useContext(AppState);

  const runSlowProcess = async () => {
    const startTime = Math.round(Date.now() / 1000);
    setProgress(0);
    setShowProcessing(true);

    const delay = () => new Promise((resolve) => setTimeout(resolve, 0));

    for (let i = 1; i < 101; i++) {
      for (let j = 1; j < 1001; j++) {
        for (let k = 1; k < 1001; k++) {
          for (let l = 1; l < 1001; l++) {
            // do the stuffs
          }
        }
      }
      console.log(i);
      setProgress(i);
      await delay();
    }
    setShowProcessing(false);
    console.log(
      "Elapsed time:",
      Math.round(Date.now() / 1000) - startTime,
      "seconds"
    );
  };

  return {
    runSlowProcess
  };
};


Enter fullscreen mode Exit fullscreen mode

Now when you click the START SLOW PROCESS button, the progress interstitial shows up on the screen, including the <CircularProgress> component with it's constantly updated progress indicator.

Notice a few things about this approach:

  1. I invoke the delay directly after I've updated the progress value inside the for loop. This has the side effect of knocking React out of the batch update process.

  2. The length of the delay is immaterial. As you can see, I'm invoking a delay of ZERO milliseconds. That may seem illogical, but it's all that's needed to trigger DOM updates in React.

Conclusion

I just wanna be clear that, in the vast majority of instances, it's truly a solid idea to avoid doing repeated React state updates inside a loop. But I wanted to highlight this use case because, when you're trying to give the user real-time info on the status of an ongoing process, it's one potential scenario where it makes total sense to do it.

Top comments (8)

Collapse
 
ecyrbe profile image
ecyrbe • Edited

Hello Adam,

Nice article as always.
You can also just do this if i'm not mistaken :

export const useSlowProcess = () => {
  const { setProgress, setShowProcessing } = useContext(AppState);

  const runSlowProcess = async () => {
    const startTime = Math.round(Date.now() / 1000);
    setProgress(0);
    setShowProcessing(true);

    for (let i = 1; i < 101; i++) {
      for (let j = 1; j < 1001; j++) {
        for (let k = 1; k < 1001; k++) {
          for (let l = 1; l < 1001; l++) {
            // do the stuffs
          }
        }
      }
      console.log(i);
      setProgress(i);
      await Promise.resolve(); // give back the hand to javascript to schedule another task like react refresh, etc
    }
    setShowProcessing(false);
    console.log(
      "Elapsed time:",
      Math.round(Date.now() / 1000) - startTime,
      "seconds"
    );
  };

  return {
    runSlowProcess
  };
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
bytebodger profile image
Adam Nathaniel Davis

NO. If you pop over to the Codesandbox, you can prove out that this doesn't work. And that's part of what made this so confusing for me to solve when I first encountered it, because I was thinking the same thing. As was noted above in a prior comment, you can find an explanation of this concept on this page:

web.dev/optimize-long-tasks/

Specifically, the page provides this helpful explanation:

Caution
While this code example returns a Promise that resolves after a call to setTimeout(), it's not the Promise that is responsible for running the rest of the code in a new task, it's the setTimeout() call. Promise callbacks run as microtasks rather than tasks, and therefore don't yield to the main thread.

In other words, the Promise alone is not sufficient to fix this "problem". You need the setTimeout() returned in the Promise.

Collapse
 
ansa70 profile image
Enrico Ansaloni

You could use an event emitter inside the loop and subscribe to it in the progress indicator, I think it's a cleaner solution, you just have to be careful and unsubscribe on component unmount

Collapse
 
pizzooid profile image
Pietro

Hey, isn't the await also needed to not fully get back to the event loop?
Have you thought about using a web worker? It seems strange to add an artificial timeout that could potentially slow the process. 🤔

Collapse
 
bytebodger profile image
Adam Nathaniel Davis • Edited

Hey, isn't the await also needed to not fully get back to the event loop?

Not sure I'm following... 🤔

Have you thought about using a web worker? It seems strange to add an artificial timeout that could potentially slow the process.

I hear ya. But I honestly think that the web worker is overkill. Remember that the "artificial timeout" is set in this example at 10 milliseconds. Even when you multiply this by 100 (for every iteration of the outer loop), you're talking about an extra second added to the process. A process that already takes 26-27 seconds.

But I'm glad that you wrote this response, because it reminds me of something that I should've written in the article, and probably should've used in the demo solution:

This "fix" actually works even if you set the setTimeout() delay to... ZERO. In other words, we don't have to inject any unneeded delay. Waiting for the promise will break React out of its batch update process - even when the "wait" is... ZERO milliseconds.

In fact, I went back and changed the Code Sandbox to now use a ZERO millisecond "delay". And it works great.

Collapse
 
liscion profile image
Harold Iedema

This sounds like something that the new scheduler.yield() function will solve perfectly. chromestatus.com/feature/626624933...

Collapse
 
dikamilo profile image
dikamilo

Yeah, probably. For now we can just deal with yield points.

Good write-up about this: web.dev/optimize-long-tasks/

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

Interesting. I hadn't heard of that. But it looks like it's currently supported nowhere else but Chrome.