Jeffrey Muller
Jeffrey Muller

Reputation: 850

React setState within setInterval

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

Answers (1)

T.J. Crowder
T.J. Crowder

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

Related Questions