Reputation: 4752
Given a list of tasks:
const [tasks, setTasks] = useState([])
I want to add a task on user input with setTasks(...tasks, aNewTask)
, and then update the results of that task asynchronously:
while (true) {
taskStatus = await getTaskStatus()
setTasks(tasks.map(t => t.id == taskStatus.id ? taskStatus : t))
}
which looks logically correct. But it doesn't work; the task is added to the list, then deleted subsequently. tasks
isn't being updated, so querying it yields the original list, before I called setTasks
the first time. The best workaround I see while still using the same pattern is to wrap useState
in a custom hook, and resolve a promise as part of the set-value function, but even then I need tasks
to be var
so I can update it locally.
Is there a cleaner way, that still uses the async logic?
Upvotes: 2
Views: 660
Reputation: 39320
When you have an effect that sets state after an asynchronous action you should check if the component is still mounted before setting that state.
Here is an example that checks before setting state and will exit the infinite loop when component is unmounted. The effect has no dependencies so will only be run after first render. The tasks is not a dependency to the effect because I pass a callback to setTasks.
const { useRef, useEffect, useState } = React;
//helper to check if component is mounted so you won't
// try to set state of an unmounted component
// comes from https://github.com/jmlweb/isMounted/blob/master/index.js
const useIsMounted = () => {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => (isMounted.current = false);
}, []);
return isMounted;
};
//returns current date after waiting for a second
function getTasksStatus() {
return new Promise(r =>
setTimeout(() => r(Date.now(), 1000))
);
}
function App() {
const [tasks, setTasks] = useState(
Math.floor(Date.now() / 1000)
);
//from the helper to check if component is still mounted
const isMounted = useIsMounted();
useEffect(() => {
//babel of Stack overflow is ancient and doesn't know what
// to do with async so I create a recursive instead
function polling() {
getTasksStatus().then(newTasks => {
//only do something if component is still mounted
if (isMounted.current) {
//pass callback to setTasks so effect doesn't depend on tasks
setTasks(currentTasks =>
Math.floor(newTasks / 1000)
);
//call recursively
polling();
}
});
}
//the async version with "infinite" loop looks like this
// async function polling() {
// //exit loop if component is unmounted
// while (isMounted.current) {
// const newTasks = await getTasksStatus();
// isMounted.current && //make sure component is still mounted
// //pass callback to setTasks so effect doesn't depend on tasks
// setTasks(currentTasks =>
// Math.floor(newTasks / 1000)
// );
// }
// }
polling();
}, [isMounted]);
return <div>{tasks}</div>;
}
ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="root"></div>
Upvotes: 1
Reputation: 4662
The problem with doing a setState
inside a loop is that it will only trigger the thing once. To make it so it will "poll" you can wrap it with a timeout function:
let timeout = null; //outside class
...
while (true) {
timeout = setTimeout(() => {
taskStatus = await getTaskStatus()
setTasks(tasks.map(t => t.id == taskStatus.id ? taskStatus : t))
}, 1000);
}
the 1000
is the number of millisecond to run the set task/poll.
make sure you clear the timeout on unmount like so:
useEffect(() => () => clearTimeout(timeout)));
This is so it doesn't poll if the component is no longer active
Upvotes: 0