Clearing multiple timeouts in useEffect

Viewed 269

Using React for practice, I'm trying to build a small notification system that disappears after a given period using timeouts.

A sample scenario;

  1. User creates one notification and wait for it's to expire
  2. The cleanup function runs from useEffect and clears the timeout.

This would be no problem and clears out the only available timeout. The problem appears when I'm adding more:

  1. Render #1 - adding the first notification
  2. Render #2 - the cleanup function calls from render #1 for adding a new notification. This adds a new notification but clears a timeout before it's done.
  3. The timeout expires from render #2 so runs the cleanup function and clears the right timeout.

It's a fairly simple component, which renders a array of objects (with the timeout in it) from a Zustand store.

export const Notifications = () => {   const { queue, } = useStore()

  useEffect(() => {
    if (!queue.length || !queue) return

    // eslint-disable-next-line consistent-return
    return () => {
      const { timeout } = queue[0]

      timeout && clearTimeout(timeout)
    }   }, [queue])

  return (
    <div className="absolute bottom-0 mb-8 space-y-3">
      {queue.map(({ id, value }) => (
        <NotificationComponent key={id} requestDiscard={() => id}>
          {value}
        </NotificationComponent>
      ))}
    </div>   ) }

My question is; is there any way to not delete a running timeout when adding a new notification? I also tried finding the last notification in the array by queue[queue.length - 1], but it somehow doesn't make any sense

My zustand store:

interface State {
  queue: Notification[]
  add: (notification: Notification) => void
  rm: (id: string) => void
}

const useNotificationStore = c<State>(set => ({
  add: (notification: Notification) =>
    set(({ queue }) => ({ queue: [...queue, notification] })),
  rm: (id: string) =>
    set(({ queue }) => ({
      queue: queue.filter(n => id !== n.id),
    })),
  queue: [],
}))

My hook for adding notifications;

export function useStoreForStackOverflow() {
  const { add, rm } = useNotificationStore()

  const notificate = (value: string) => {
    const id = nanoid()
    const timeout = setTimeout(() => rm(id), 2000)

    return add({ id, value, timeout })
  }

  return { notificate }
}
2 Answers

I think with a minor tweak/refactor you can instead use an array of queued timeouts. Don't use the useEffect hook to manage the timeouts other than using a single mounting useEffect to return an unmounting cleanup function to clear out any remaining running timeouts when the component unmounts.

Use enqueue and dequeue functions to start a timeout and enqueue a stored payload and timer id, to be cleared by the dequeue function.

const [timerQueue, setTimerQueue] = useState([]);

useEffect(() => {
  return () => timerQueue.forEach(({ timerId }) => clearTimeout(timerId));
}, []);

const enqueue = (id) => {
  const timerId = setTimeout(dequeue, 3000, id);
  setTimerQueue((queue) =>
    queue.concat({
      id,
      timerId
    })
  );
};

const dequeue = (id) =>
  setTimerQueue((queue) => queue.filter((el) => el.id !== id));

Demo

Edit clearing-multiple-timeouts-in-useeffect

You can use useRef here to track all timeouts in a Map.

The cool thing about Map is that you can use your Notification as key and the timer as value.

I created a small POC with useState but it should also work fine with Zustand:
https://codesandbox.io/s/young-worker-hg38x

import { useEffect, useRef, useState } from "react";

export default function App() {

  const [notifications, setNotification] = useState<{message: string}[]>([]);
  const timers = useRef(new Map());

  useEffect(() => {
    const newNotifications = notifications.filter((notification) => !timers.current.has(notification));
    newNotifications.forEach((newNotification) => {
      timers.current.set(newNotification, setTimeout(() => {
        // Remove notification
        setNotification((currentNotifications) => currentNotifications.filter((notification) => notification !== newNotification));
        // Remove notification from timer tracker:
        timers.current.delete(newNotification)
      }, 3000));
    });
  }, [notifications])

  // Clear all timers on unmount
  useEffect(() => {
    return () => {
      [...timers.current.values()].forEach((timer) => {
        clearTimeout(timer);
      })
    }
  }, [])

  return (
    <div className="App">
      {notifications.map((notification) => <p>{notification.message}</p>)}
      <button onClick={() => {
        setNotification([...notifications, {
          message: 'notification' + Math.random()
        }]);
      }}>add</button>
    </div>
  );
}
Related