Toast Notifications — Global UI State with Context

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

🧠 Test Yourself

Why does the ToastContainer render inside the ToastProvider’s JSX rather than in App.jsx or a separate root?