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.
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>
</>
}
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:
- A user performs some action (like clicking a button).
- The state variable is updated.
- React's reconciliation process is triggered.
- This process realizes that there's now a diff between the virtual DOM and the actual DOM.
- 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.
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:
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.
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.
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.
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
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>
</>
);
};
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>
</>
);
};
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
};
};
useSlowProcess()
returns a single function: runSlowProcess()
. runSlowProcess()
does the following:
It establishes a
startTime
so we can calculate just how long theSLOW PROCESS
actually takes. (After playing with this repeatedly on Code Sandbox, I can tell you that it takes ~27 seconds to complete.)It ensures that our
progress
variable starts at0
.It then sets
showProcessing
totrue
so the user will see the in-progress interstitial.It then launches into the
SLOW PROCESS
.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.
After each percentage is completed, we
console.log()
the current progress and we update theprogress
variable. Remember, that variable will be used to show the user how close we are to completion.When the process is complete, we set
showProcessing
back tofalse
. This should remove the in-progress interstitial.Finally, we
console.log()
the total number of seconds that it took to complete theSLOW 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?
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.
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} />
);
}
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...
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
};
};
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:
I invoke the delay directly after I've updated the
progress
value inside thefor
loop. This has the side effect of knocking React out of the batch update process.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)
Hello Adam,
Nice article as always.
You can also just do this if i'm not mistaken :
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:
In other words, the Promise alone is not sufficient to fix this "problem". You need the
setTimeout()
returned in the Promise.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
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. 🤔
Not sure I'm following... 🤔
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.
This sounds like something that the new
scheduler.yield()
function will solve perfectly. chromestatus.com/feature/626624933...Yeah, probably. For now we can just deal with yield points.
Good write-up about this: web.dev/optimize-long-tasks/
Interesting. I hadn't heard of that. But it looks like it's currently supported nowhere else but Chrome.