In this code I would like the output to print "Output:,1,2,3,4,5" But the output variable is stale because I iteratively call the function after a promise is returned from a closure.
What is an efficient way to keep a loop with this timed sleep effect, and get my desired result?
Code Sandbox: https://codesandbox.io/s/hidden-shape-6uh5jm?file=/src/App.js:0-1092
import "./styles.css";
import React, { useEffect, useState } from "react";
export default function App() {
const [output, setOutput] = useState("Output: ");
const [isLooping, setIsLooping] = useState(false);
const sleep = (ms) => {
return function (x) {
return new Promise((resolve) => setTimeout(() => resolve(x), ms));
};
};
const startLoop = () => {
if (isLooping) {
console.log("Click ignored - because is Looping");
return;
} else {
setIsLooping(true);
runLoop();
}
};
const runLoop = (maxIterations = 0) => {
setOutput(output + ", " + maxIterations);
if (maxIterations >= 5) {
setIsLooping(false);
return;
}
sleep(1000)().then(() => {
runLoop(maxIterations + 1);
});
};
useEffect(() => {
startLoop();
}, []);
return (
<div className="App">
<p>{output}</p>
<button
value="Start Loop"
onClick={() => {
startLoop();
}}
>
Start loop
</button>
{isLooping && <p>Is Looping</p>}
</div>
);
}
Any time the next state value depends on the previous state value, e.g. like string concatenation of a value to the previous value, you will want to use a functional state update.
Update the runLoop
handler to use a functional state update, it's really a rather trivial change: setOutput(output + ", " + maxIterations);
to setOutput(output => output + ", " + maxIterations);
.
const runLoop = (maxIterations = 0) => {
setOutput(output => output + ", " + maxIterations);
if (maxIterations >= 5) {
setIsLooping(false);
return;
}
sleep(1000)().then(() => {
runLoop(maxIterations + 1);
});
};
This results in an odd output though: "Output: , 0, 1, 2, 3, 4, 5"
This is basically a classic fencepost problem.
This is the result of mixing the UI you want to render with the data you want to render it from, all into state. Keep in mind that in React, UI (e.g. what is rendered) is a function of state and props. The React state should contain just the data you need to store and the React component maps the state to the rendered UI.
Here's an example of storing just the maxIterations
value and rendering the computed output string from that state.
import "./styles.css";
import React, { useEffect, useState } from "react";
export default function App() {
const [output, setOutput] = useState([]); // <-- initial empty array
const [isLooping, setIsLooping] = useState(false);
const sleep = (ms) => {
return function (x) {
return new Promise((resolve) => setTimeout(() => resolve(x), ms));
};
};
const startLoop = () => {
if (isLooping) {
console.log("Click ignored - because is Looping");
return;
} else {
// reset array for next looping
setOutput([]);
setIsLooping(true);
runLoop();
}
};
const runLoop = (maxIterations = 0) => {
setOutput((output) => output.concat(maxIterations)); // <-- append new value, return new array
if (maxIterations >= 5) {
setIsLooping(false);
return;
}
sleep(1000)().then(() => {
runLoop(maxIterations + 1);
});
};
useEffect(() => {
startLoop();
}, []);
return (
<div className="App">
<p>Output: {output.join(", ")}</p> // <-- compute rendered UI
<button
value="Start Loop"
onClick={() => {
startLoop();
}}
>
Start loop
</button>
{isLooping && <p>Is Looping</p>}
</div>
);
}
The rendered output will now be the more expected "Output: 0, 1, 2, 3, 4, 5"