Toast notifications — the small popups that say “Post saved” or “Error: Login failed” — are a global UI concern. Any component in the tree can trigger a toast, but the toast container must render in a fixed overlay above all content. This is a perfect use case for Context: the ToastProvider manages the list of active toasts, the useToast hook gives any component the ability to add a toast, and the ToastContainer renders them in a portal above everything else.
Toast Context Implementation
// src/context/ToastContext.jsx
import { createContext, useContext, useState, useCallback, useId } from "react";
const ToastContext = createContext(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be inside ToastProvider");
return ctx;
}
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((message, type = "info", duration = 4000) => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, type }]);
// Auto-dismiss after duration
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id; // return ID so caller can dismiss early
}, []);
const removeToast = useCallback((id) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
// Convenience shortcuts
const toast = {
success: (msg, dur) => addToast(msg, "success", dur),
error: (msg, dur) => addToast(msg, "error", dur ?? 6000),
info: (msg, dur) => addToast(msg, "info", dur),
warning: (msg, dur) => addToast(msg, "warning", dur),
};
return (
<ToastContext.Provider value={toast}>
{children}
<ToastContainer toasts={toasts} onDismiss={removeToast} />
</ToastContext.Provider>
);
}
Note: The
ToastContainer renders inside the ToastProvider‘s JSX but it needs to appear above all other content visually. This is handled by Tailwind’s fixed positioning with a high z-index — no React Portal is strictly necessary unless you have stacking context issues. However, for production applications, rendering the container in a Portal (ReactDOM.createPortal(<Container />, document.body)) ensures no stacking context from parent elements clips the toasts.Tip: Keep toast messages short and actionable: “Post saved” not “Your post has been successfully saved to the database”. For error toasts, include enough context to understand the problem: “Login failed: Invalid email or password” rather than just “Error”. Avoid showing raw API error messages directly — map status codes to user-friendly text in the calling code. Set different durations for different types: success toasts can disappear quickly (3s), error toasts should stay longer (6s) since users may need to read them.
Warning: Be careful with
setTimeout inside useCallback for auto-dismiss. The removeToast function used inside the timeout must be stable (not recreated on every render). Since removeToast is wrapped in useCallback([], []), it is stable. If you had used a plain function, the timeout would capture an outdated version of the function, and calling it might not work correctly after the component re-renders.ToastContainer Component
// src/components/ui/ToastContainer.jsx
function ToastContainer({ toasts, onDismiss }) {
if (!toasts.length) return null;
return (
<div
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
aria-live="polite"
aria-label="Notifications"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
}
function ToastItem({ toast, onDismiss }) {
const styles = {
success: "bg-green-600 text-white",
error: "bg-red-600 text-white",
info: "bg-blue-600 text-white",
warning: "bg-yellow-500 text-gray-900",
};
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg min-w-64 max-w-sm
${styles[toast.type] ?? styles.info}`}
role="alert"
>
<span className="flex-1 text-sm font-medium">{toast.message}</span>
<button
type="button"
onClick={() => onDismiss(toast.id)}
className="opacity-75 hover:opacity-100 text-lg leading-none"
aria-label="Dismiss notification"
>
×
</button>
</div>
);
}
Using Toasts in Components
import { useToast } from "@/context/ToastContext";
import { postsApi } from "@/services/posts";
function PostEditor() {
const toast = useToast();
async function handleSave(formData) {
try {
await postsApi.create(formData);
toast.success("Post published successfully!");
} catch (err) {
if (err.response?.status === 409) {
toast.error("A post with this slug already exists");
} else {
toast.error("Failed to save post — please try again");
}
}
}
}
// Wiring in main.jsx (ToastProvider wraps the entire app):
// <ToastProvider>
// <AuthProvider>
// <BrowserRouter><App /></BrowserRouter>
// </AuthProvider>
// </ToastProvider>
Common Mistakes
Mistake 1 — Not using aria-live on the toast container
❌ Wrong — screen readers don’t announce new toasts:
<div className="fixed bottom-4 right-4"> {/* no aria-live */}
✅ Correct — aria-live="polite" announces new toasts to screen readers when they appear.
Mistake 2 — setTimeout without cleanup if component unmounts
❌ Wrong — timer fires after unmount, tries to update state:
setTimeout(() => removeToast(id), 4000); // runs after unmount if provider unmounts!
✅ Better — store timeout IDs in a ref and clear on unmount, or use a cleanup approach.
Quick Reference
| Method | Type | Default Duration |
|---|---|---|
toast.success(msg) |
Green | 4 seconds |
toast.error(msg) |
Red | 6 seconds |
toast.info(msg) |
Blue | 4 seconds |
toast.warning(msg) |
Yellow | 4 seconds |