Search code examples
javascripthtmlreactjsreact-nativeredux

How to create a countdown timer for each item in an array?


In my React app, I have a list of orders which are supposed to be shown to the user for only 30 seconds so each order has a value of 30 seconds for its duration propery:

[
  {
    ...,
    ...,
    duration: 30
  },
  {
    ...,
    ...,
    duration: 30
  },
  ...
]

I'm using Redux Toolkit to store their data so that I can render the UI of these items in various components. I tried to create an action which gets dispatched every 1 second to decrement the duration by one:

decrementCountdown: (state, action) => {
  const order = state.entities[action.payload];
  
  if (order) order.duration -= 1;
}

Then, in App.jsx, I dispatch the action using setInterval inside a loop:

useEffect(() => {
  let countdown;

  for (order of orders) {
    // Run until the duration reaches zero
    if (order.duration > 1) {
      countdown = setInterval(() => dispatch(decrementCountdown(order?.id)), 1000);
    }
  }

return () => clearInterval(countdown);
}, [orders])

The challenging part is that the timers have to be synched so that everywhere that the items are shown, the same remaining time is shown and decremented.

The method I have used didn't help me much. Especially when more that one order was present. In that case, the new order's duration wouldn't decrement and it caused an infinite loop inside the useEffect.

Is there any way I can create a countdown for each one?


Solution

  • Thanks to @Alexey's response and comment I figured that I needed to create a separate Redux Slice dedicated to each order's duration. I was already using the Entity Adapter of Redux Toolkit so the order and duration slices looked like this:

    orderSlice.js:

    order: {
      ids: [
        0: 500,
        1: 600
      ],
      entities: {
        500: {
          id: 500,
          ...
        },
        600: {
          id: 600,
          ...
        },
      }
    }
    

    orderDurationSlice.js:

    orderDurtaion: {
      ids: [
        0: 500,
        1: 600
      ],
      entities: {
        500: {
          id: 500,
          duration: 30
        },
        600: {
          id: 600,
          duration: 30
        },
      }
    }
    

    After each order arrives via a Socket connection, it gets added into both of the slices (Only the order id gets added to the orderDuration slice in order to correspond to the complete order object inside the order slice).

    The challenging part was to start the countdown timer for each one of the orders as soon as they arrive and also have it as a top-level service running independently of the component lifecycle so that each component that requires it could access it.

    I was already using Redux Toolkit's Listener Middleware to handle the Socket connection and after someone on Reddit recommended the use of Redux's middleware to handle this, I realized that I just had to create another listener as a kind of countdown service.

    This listener would only run if the orderDuration slice is filled and the instance of setInterval is undefined.

    socketListenerMiddleware.js:

    // Order countdown interval instance
    let countdown;
    
    // Create the middleware instance and methods
    const socketListenerMiddleware = createListenerMiddleware();
    
    // Handle each order's duration countdown after a new order arrives
    socketListenerMiddleware.startListening({
      predicate: (action, currentState, previousState) => {
        // Check if the interval instance is undefined and the order duration slice is full
        return !countdown && currentState.orderDuration?.ids.length;
      },
      effect: (action, listenerApi) => {
        // Cancel any in-progress instances of this listener
        listenerApi.cancelActiveListeners();
    
        // Handle the countdown
        countdown = setInterval(() => {
          // Select all of the order durations
          // These are being selected inside of the interval to always get the most-updated array
          const orderDurations = orderDurationSelectors.selectAll(
            listenerApi.getState(),
          );
    
          // Loop through each order duration to get each item
          for (const order of orderDurations) {
            // Start the countdown
            listenerApi.dispatch(decrementDuration(order?.id));
    
            // If the order duration state is less than or equal to zero, remove the order and its corresponding duration
            if (order?.duration <= 0) {
              listenerApi.dispatch(removeOrder(order?.id));
              listenerApi.dispatch(removeDuration(order?.id));
            }
          }
    
          // If the order duration slice gets empty, clear the interval and reset its instance
          if (!orderDurations.length) {
            clearInterval(countdown);
            countdown = undefined;
          }
        }, 1000);
      },
    });
    

    The main part which handles the countdown is here:

    Here, only one instance of setInterval is created which loops over the orderDuration array to get each one of the items and dispatches an action which decrements each order's duration by 1.

    If the order's duration gets to zero, two dispatched actions remove the order (in order slice) and its corresponding duration (in orderDuration slice).

    Finally, outside of the loop an if condition checks if the orderDuration slice gets empty and if so, it clears the interval and resets its instance.

    // Handle the countdown
        countdown = setInterval(() => {
          // Select all of the order durations
          // These are being selected inside of the interval to always get the most-updated array
          const orderDurations = orderDurationSelectors.selectAll(
            listenerApi.getState(),
          );
    
          // Loop through each order duration to get each item
          for (const order of orderDurations) {
            // Start the countdown
            listenerApi.dispatch(decrementDuration(order?.id));
    
            // If the order duration state is less than or equal to zero, remove the order and its corresponding duration
            if (order?.duration <= 0) {
              listenerApi.dispatch(removeOrder(order?.id));
              listenerApi.dispatch(removeDuration(order?.id));
            }
          }
    
          // If the order duration slice gets empty, clear the interval and reset its instance
          if (!orderDurations.length) {
            clearInterval(countdown);
            countdown = undefined;
          }
        }, 1000);
    

    I then access each order's duration via selector's inside components.

    Hope this solution helps anyone dealing with this kind of question.