skip to Main Content

Not sure if this a React issue or not. I have an array of objects in state and am mapping each object to a component. Inside this component, I have a useEffect that sets a timeout, when the timeout completes, the object is removed from state, causing the component to be unmounted. The problem I have is, the setTimeout callback is not running until all of the current setTimeouts have expired and then they all run at the same time. I confirmed it’s not state batching by adding a console.log to the setTimeout callback and even that does not run until the last setTimeout has completed.

Here is where I map the components. removeToast is the function called to remove the object from the array in state.

        <div className="toastsContainer">
          {toasts.map((toast, i) => (
            <Toast key={i} toast={toast} removeToast={removeToast} />
          ))}
        </div>

removeToast function:

  const removeToast = (toast: IToast) => {
    setToasts((prev) => prev.filter(({ id }) => id !== toast.id));
  };

Here is the Toast component with the setTimeout

import { useEffect } from "react";
import IToast from "../interfaces/toast";
import "../styles/toast.css";
import { useSpring, animated } from "react-spring";

const TOAST_DURATION = 3000;
interface ToastProps {
  toast: IToast;
  removeToast: (toast: IToast) => void;
}

export function Toast({ toast, removeToast }: ToastProps) {
  const { width } = useSpring({
    from: { width: "100%" },
    to: { width: "0%" },
    config: { duration: TOAST_DURATION },
  });

  useEffect(() => {
    const timerId = setTimeout(() => {
      removeToast(toast);
    }, TOAST_DURATION);

    return () => clearTimeout(timerId);
  }, [toast, removeToast]);

  return (
    <div className="toast">
      <div className="message"> {toast.message}</div>
      <animated.div
        className="duration"
        style={{
          width,
        }}
      />
    </div>
  );
}

Tried having the useEffect in the parent component and mapping over the objects and setting a setTimeout there. This just results in multiple setTimeouts being applied to each object everytime an object is added.

Also tried comparing the objects directly instead of having an ID thinking it might be my filter function. This did not change any behavior.

2

Answers


  1. The "set state function" is asynchronous. When you call that function, what it does is put a call onto the "asynch queue" to be executed "at some point in the future". When that function runs, it will queue the component that owns the state variable to be re-rendered.

    React is pretty decent in terms of batching a bunch of "set state function" calls and running most/all of them prior to actually re-rendering the component that owns them.

    Since it looks like all of your components are essentially being batched to run at the same timeout (+/- a couple of milliseconds), it is likely that what you are seeing is that the parent component is not rendering until all of the child components’ calls to removeToast() (which each call setToasts()) complete their runs.

    One experiment you might try: send the index value from the .map(), which is your variable i, to each child and have them setTimeout to TOAST_DURATION * i. Then see how the app behaves.

    Login or Signup to reply.
  2. The useEffect hook has the removeToast function as a dependency which will have a different reference on each render of the parent component resulting in the trigger of the useEffect hook clearing the previous setTimeout timerId and creating a new id on each new toast added.

    Suggestion:

    • Try Removing the removeToast function from the dependency array (ignoring the lint warning).

    • Or Wrap the removeToast in useCallback hook. doing so you can safely add it to the dependency array.

        const removeToast = useCallback((toast: ToastData) => {
        setToasts((prev) => prev.filter(({ id }) => id !== toast.id));
      }, []);
      
    • Also you can use useRef to track the setTimeout timerId instead of local variable inside useEffect.

       const timerIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
      
    • useEffect hook will look like this

    However this won’t solve the multiple toast being removed at the same time as multiple toast might appear at the same time.

    useEffect(() => {
        timerIdRef.current = setTimeout(() => {
            removeToast(toast);
        }, TOAST_DURATION);
        return () => {
            if (timerIdRef.current) {
                clearTimeout(timerIdRef.current);
                timerIdRef.current = null;
            }
        }
    }, [toast, removeToast]);
    
    • If possible, I suggest you to use libraries like react-toastify or other libraries. I personally use it.
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search