Skip to content
All notes

July 9, 2026 · 7 min

Toast notifications with a tiny React context

A queue, a portal, and a timeout. No toast library. Just a provider you can drop around the app.

  • React
  • TypeScript

Try it outClick around. This is the real component.

toast.tsx

Preview

1"use client";
2
3import { createContext, useCallback, useContext, useMemo, useState } from "react";
4import { createPortal } from "react-dom";
5
6type Toast = { id: number; message: string };
7type ToastContextValue = { push: (message: string) => void };
8
9const ToastContext = createContext<ToastContextValue | null>(null);
10
11export function ToastProvider({ children }: { children: React.ReactNode }) {
12 const [toasts, setToasts] = useState<Toast[]>([]);
13
14 const push = useCallback((message: string) => {
15 const id = Date.now() + Math.random();
16 setToasts((current) => [...current, { id, message }]);
17 window.setTimeout(() => {
18 setToasts((current) => current.filter((toast) => toast.id !== id));
19 }, 3200);
20 }, []);
21
22 const value = useMemo(() => ({ push }), [push]);
23
24 return (
25 <ToastContext.Provider value={value}>
26 {children}
27 {typeof document !== "undefined"
28 ? createPortal(
29 <div
30 className="pointer-events-none fixed right-4 bottom-4 z-50 flex w-80 flex-col gap-2"
31 aria-live="polite"
32 aria-relevant="additions"
33 >
34 {toasts.map((toast) => (
35 <div
36 key={toast.id}
37 className="rounded-xl border border-white/10 bg-zinc-950 px-4 py-3 text-sm shadow-2xl"
38 >
39 {toast.message}
40 </div>
41 ))}
42 </div>,
43 document.body,
44 )
45 : null}
46 </ToastContext.Provider>
47 );
48}
49
50export function useToast() {
51 const context = useContext(ToastContext);
52 if (!context) {
53 throw new Error("useToast must be used inside ToastProvider");
54 }
55 return context;
56}

Toasts are a global concern: any button, any form, any fetch should be able to say “saved” without threading a setter down the tree. That is a context, not a component prop.

The implementation is a list of messages, a push function, and a portal that renders them in a live region so screen readers hear the update.

Firing a toast

example.tsx
1"use client";
2
3import { useToast } from "./toast";
4
5export function SaveButton() {
6 const { push } = useToast();
7
8 return (
9 <button
10 type="button"
11 onClick={() => push("Saved. The queue will dismiss this in a few seconds.")}
12 >
13 Save
14 </button>
15 );
16}

Where this stops being enough

Add variants (success or danger), a timer that pauses on hover, and an undo action when you need them. Until then, this is the entire feature: a queue and a portal. Most apps never outgrow it.