Reputation: 850
I have a component in which I store an array of objects such as:
const [items, setItems] = React.useState([]);
I can add some elements to this array by clicking on a button:
const addItem = () => {
setItems(items => [ ...items, { value: 'foo', color: 'yellow' }]);
};
Now, I would like to make every items in my list blink from yellow to red every 500ms for 5s. So I created a generic timer function to help me such as:
const timer = (timeout: number, interval: number, callback: (n: number) => void) => {
const start = new Date().getTime();
let n = 0;
const _timer = setInterval(() => {
if ((new Date().getTime() - start) >= timeout) {
clearInterval(_timer);
} else {
callback(++n);
}
}, interval);
};
I trigger that timer by doing the following from my component:
React.useEffect(() => {
// I also store the previous state, so that I trigger a timer only when new items
// are added to my array. Yeah not ideal...
if (items.length > previousItems.length) {
const lastItem = items.length - 1;
timer(5000, 500, (n: number) => {
const updatedItems = [ ...items ];
updatedItems[index].color = n % 2 == 0 ? 'yellow' : 'red';
setItems(items => updatedItems);
});
}
});
This flawed approach works ok in case I add only the items to the list one by one (e.g. the timer is completed before adding a new item). But it's completely broken when adding multiple items at the same time: the first timer that has been created does not take into account all the items that have been added later. I understand this approach is not ideal at all, but if you have better way to achieve what I want, I would be grateful. Thanks.
Upvotes: 1
Views: 859
Reputation: 1075199
Instead of using the old items
, use the one your setter callback gets passed instead, see the ***
comment:
React.useEffect(() => {
if (items.length > previousItems.length) {
const lastItem = items.length - 1;
timer(5000, 500, (n: number) => {
// *** Move the entire update into the callback
setItems(items => {
const updatedItems = [ ...items ]; // <=== Now this is using the up-to-date `items`
updatedItems[index].color = n % 2 == 0 ? 'yellow' : 'red';
return [updatedItems];
});
});
}
});
You also need to be sure to cancel the interval timer when the component is unmounted or re-renders, since your code is starting a new timer when it re-renders (if the length check passes). Those will quickly stack up doing multiple overlapping updates.
You could have timer
return a cancel
function:
const timer = (timeout: number, interval: number, callback: (n: number) => void) => {
const start = new Date().getTime();
let n = 0;
const _timer = setInterval(() => {
if ((new Date().getTime() - start) >= timeout) {
clearInterval(_timer);
} else {
callback(++n);
}
}, interval);
return () => clearInterval(_timer); // ***
};
and then use it as the useEffect
cleanup callback:
React.useEffect(() => {
if (items.length <= previousItems.length) {
return;
}
const lastItem = items.length - 1;
const cancel = timer(5000, 500, (n: number) => {
// ^^^^^^^^^^^^
// *** Move the entire update into the callback
setItems(items => {
const updatedItems = [ ...items ]; // <=== Now this is using the up-to-date `items`
updatedItems[index].color = n % 2 == 0 ? 'yellow' : 'red';
return [updatedItems];
});
});
return cancel; // ***
});
Upvotes: 2