Building My Own React Toast Component: A Learning Journey

You can find the complete implementation on GitHub:
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 liketoast.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 ondocument
.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 thedetail
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
Subscribe to my newsletter
Read articles from Laxmikant Nirmohi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
