Building My Own React Toast Component: A Learning Journey

You can find the complete implementation on GitHub:

👉 react-toast-system

1. The Challenge

I wanted to build a toast notification system in React, similar to popular libraries like react-toastify.
The main goals were:

  • Have a single ToastContainer mounted at the root.

  • Provide a simple toast object with functions like toast.success(), toast.error(), etc.

  • Keep the API clean and easy to use.

2. Thinking About the Approach

My first thought was: Can I trigger an event and listen to it somewhere globally?

  • Initially, I considered using the default Event and listening on document.

  • The problem: I couldn’t pass extra data (like message or type of toast).

That’s when I came across CustomEvent in JavaScript. It allows you to attach custom data to an event via the detail property, which is perfect for passing toast message, type, etc. (developer.mozilla.org)

Later, I ran the idea through ChatGPT, and it suggested using EventTarget a focused event emitter that avoids polluting document. (stefanjudis.com)

generateToastDetail function will receive arguments, use them to create a new CustomEvent, and dispatch it through the event bus, so that any registered listener can handle it and add a toast object to the state.

toastBus.ts

import type { ToastStates } from "../components/ToastReducer";

export type ToastEventDetail = {
  id: string;
  type: "SUCCESS" | "ERROR" | "INFO";
  show: boolean;
  msg?: string;
  duration?: number;
  component?: React.ReactNode;
};

export const toastEventTarget = new EventTarget();

// ...

function generateToastDetail({
  msg,
  type,
  duration

}: {
  msg: string;
  type: ToastStates;
  duration?: number;
}) {
  const event = new CustomEvent<ToastEventDetail>("toast", {
    detail: { msg, id: generateId(), type, show: true, duration },
  });
  toastEventTarget.dispatchEvent(event);
}

3. Positioning the Toasts

Next, I needed to figure out where and how the toast should appear.

  • At first, I made the ToastContainer absolutely positioned at the top‑right corner.

  • Problem: when animating the toast in from outside the viewport, it caused overflow and triggered a scrollbar.

After some digging, I learned that position: fixed avoids these issues because fixed elements stay in view and don’t affect document flow or cause scrollbars when animating in from off-screen. (mdn - position)

ToastContainer.tsx

function ToastContainer() {
  const [toasts, dispatch] = useReducer(toastReducer, initialState);

  // ...

  return (
    <ToastContext value={{toasts, dispatch}}>
      <div id="toast-container" className="fixed top-2 right-2 flex flex-col gap-3">
        {toasts.map(i => (
          <ToastComponent toast={i} key={i.id} closeToast={closeToast} />
        ))}
      </div>
    </ToastContext>
  );
}

function ToastComponent({ toast, closeToast }: { toast: ToastEventDetail; closeToast: (id: string) => void }) {
  const {dispatch} = useToastContext();
  const [visible, setVisible] = useState(toast.show);

  // ...

  if (!visible) {
    return null;
  }

  return (
    <animated.div style={{ ...styles }} className="relative shadow rounded">
      <ToastExpiryIndicator type={toast.type} />
      <div className="flex flex-col  p-2 min-w-60 min-h-20 bg-white">
        <button
          className="absolute top-1 right-2 text-gray-300 rounded self-end hover:cursor-pointer hover:text-gray-500 active:scale-90"
          onClick={handleClose}
        >
          <CloseIcon />
        </button>
        <p>{toast.msg}</p>
      </div>
    </animated.div>
  );
};

4. Adding Animations with react‑spring

A plain toast sliding in and out didn’t feel great. I wanted a smooth, friendly effect.

I chose react-spring to achieve a natural spring-like animation on the toast’s translateX transition. I discovered this library while reading an article. The toast now bounces in and fades out gracefully, exactly the way I wanted. There’s still some work left to handle smooth transitions when a toast moves up after the one above it is removed.

function ToastComponent({ toast, closeToast }: { toast: ToastEventDetail; closeToast: (id: string) => void }) {
  const {dispatch} = useToastContext();
  const [visible, setVisible] = useState(toast.show);

  const [styles, api] = useSpring(() => ({
    from: { transform: 'translateX(120%)' },
    to: { transform: 'translateX(0%)' },
    config: { tension: 220, friction: 20 }
  }));

  // On unmount/close trigger leave animation
  const handleClose = () => {
    api.start({
      to: { transform: 'translateX(120%)' },
      onResolve: () => {
        closeToast(toast.id);
        dispatch({type: 'DISCARD'});
      },
    });
  };

  useEffect(() => {
    if (!toast.show) {
      api.start({
        to: { transform: "translateX(120%)" },
        onResolve: () => {
          setVisible(false);
          closeToast(toast.id);
          dispatch({type: 'DISCARD'});
        },
      });
    }
  }, [toast.show, api, closeToast, toast.id, dispatch]);

  if (!visible) {
    return null;
  }

  return (
    <animated.div style={{ ...styles }} className="relative shadow rounded">
      // ...
    </animated.div>
  );
};

5. Showing and Hiding Toasts

To trigger a toast:

  • A CustomEvent is dispatched with the message passed in the detail object.

  • The ToastContainer listens for this event and updates state to show the toast.

  • Each toast hides automatically after a few seconds using setTimeout.

I also used AbortController in the useEffect cleanup callback to remove event listeners when the component unmounts.

function ToastContainer() {
  const [toasts, dispatch] = useReducer(toastReducer, initialState);

  const timeoutRef = useRef<number[]>([]);

  useEffect(() => {
    const controller = new AbortController();
    const timeouts = timeoutRef.current;

    toastEventTarget.addEventListener('toast', (e) => {
      const toastEvent = e as CustomEvent<ToastEventDetail>;
      const { msg, id, type, show } = toastEvent.detail;

      dispatch({ type: 'ADD', payload: { msg, id, show, type } });

      const timeoutId = setTimeout(() => {
        dispatch({ type: 'HIDE', payload: id });
      }, 2500);

      timeoutRef.current.push(timeoutId);
    }, { signal: controller.signal });

    return () => {
      if (timeouts) {
        for (const i of timeouts) {
          clearTimeout(i);
        }
      }
      controller.abort();
    };
  }, [dispatch]);

  const closeToast = useCallback((id: string) => {
    dispatch({ type: 'HIDE', payload: id });
  }, [dispatch]);

  return (
    <ToastContext value={{toasts, dispatch}}>
     // ...
    </ToastContext>
  );
}

6. Managing Toasts with useReducer

I used useReducer to manage toast state to:

  • add a new toasts

  • mark them hidden after a timeout

  • remove them entirely after their exit animation completes

ToastReducer.tsx

import type { ToastEventDetail } from "../utils/toastBus";

export type ToastStates = "SUCCESS" | "ERROR" | "INFO";

export type ToastActions =
  | { type: 'HIDE', payload: string }
  | { type: 'ADD', payload: ToastEventDetail }
  | { type: 'DISCARD' };

export function toastReducer(state: ToastEventDetail[], action: ToastActions) {
  switch (action.type) {
    case 'HIDE':
      return state.map(i => {
        if (i.id === action.payload) {
          return { ...i, show: false };
        }
        return i;
      });
    case 'ADD':
      return [...state, action.payload];
    case 'DISCARD':
      return state.filter(i => i.show);
    default:
      throw new Error('Invalid action type');
  }
}

7. Refactoring for Clean Structure

To keep the project organized, I refactored:

  • The reducer into its own file (ToastReducer.tsx)

  • The context into another (ToastContext.tsx)

  • Created a useToastContext() custom hook to avoid null values and make context safe and easy to use

ToastContext.tsx

export const ToastContext = createContext<{
  toasts: ToastEventDetail[],
  dispatch: ActionDispatch<[action: ToastActions]>
} | null>(null);

export default function useToastContext() {
  const context = useContext(ToastContext);

  if (!context) {
    throw new Error("ToastContext not provided");
  }

  return context;
}

8. What’s Next (TODOs)

There are still cool improvements ahead:

  • Support passing a custom component to show inside the toast

  • Allow passing custom hide durations instead of a fixed timeout

  • Add more toast types, like a loading indicator or warning toast


0
Subscribe to my newsletter

Read articles from Laxmikant Nirmohi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Laxmikant Nirmohi
Laxmikant Nirmohi