use-toast.ts
1 // Inspired by react-hot-toast library 2 import * as React from "react"; 3 4 import type { ToastActionElement, ToastProps } from "./toast"; 5 6 const TOAST_LIMIT = 1; 7 const TOAST_REMOVE_DELAY = 1000000; 8 9 type ToasterToast = ToastProps & { 10 id: string; 11 title?: React.ReactNode; 12 description?: React.ReactNode; 13 action?: ToastActionElement; 14 }; 15 16 const actionTypes = { 17 ADD_TOAST: "ADD_TOAST", 18 UPDATE_TOAST: "UPDATE_TOAST", 19 DISMISS_TOAST: "DISMISS_TOAST", 20 REMOVE_TOAST: "REMOVE_TOAST", 21 } as const; 22 23 let count = 0; 24 25 function genId() { 26 count = (count + 1) % Number.MAX_VALUE; 27 return count.toString(); 28 } 29 30 type ActionType = typeof actionTypes; 31 32 type Action = 33 | { 34 type: ActionType["ADD_TOAST"]; 35 toast: ToasterToast; 36 } 37 | { 38 type: ActionType["UPDATE_TOAST"]; 39 toast: Partial<ToasterToast>; 40 } 41 | { 42 type: ActionType["DISMISS_TOAST"]; 43 toastId?: ToasterToast["id"]; 44 } 45 | { 46 type: ActionType["REMOVE_TOAST"]; 47 toastId?: ToasterToast["id"]; 48 }; 49 50 interface State { 51 toasts: ToasterToast[]; 52 } 53 54 const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); 55 56 const addToRemoveQueue = (toastId: string) => { 57 if (toastTimeouts.has(toastId)) { 58 return; 59 } 60 61 const timeout = setTimeout(() => { 62 toastTimeouts.delete(toastId); 63 dispatch({ 64 type: "REMOVE_TOAST", 65 toastId: toastId, 66 }); 67 }, TOAST_REMOVE_DELAY); 68 69 toastTimeouts.set(toastId, timeout); 70 }; 71 72 export const reducer = (state: State, action: Action): State => { 73 switch (action.type) { 74 case "ADD_TOAST": 75 return { 76 ...state, 77 toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 78 }; 79 80 case "UPDATE_TOAST": 81 return { 82 ...state, 83 toasts: state.toasts.map((t) => 84 t.id === action.toast.id ? { ...t, ...action.toast } : t, 85 ), 86 }; 87 88 case "DISMISS_TOAST": { 89 const { toastId } = action; 90 91 // ! Side effects ! - This could be extracted into a dismissToast() action, 92 // but I'll keep it here for simplicity 93 if (toastId) { 94 addToRemoveQueue(toastId); 95 } else { 96 state.toasts.forEach((toast) => { 97 addToRemoveQueue(toast.id); 98 }); 99 } 100 101 return { 102 ...state, 103 toasts: state.toasts.map((t) => 104 t.id === toastId || toastId === undefined 105 ? { 106 ...t, 107 open: false, 108 } 109 : t, 110 ), 111 }; 112 } 113 case "REMOVE_TOAST": 114 if (action.toastId === undefined) { 115 return { 116 ...state, 117 toasts: [], 118 }; 119 } 120 return { 121 ...state, 122 toasts: state.toasts.filter((t) => t.id !== action.toastId), 123 }; 124 } 125 }; 126 127 const listeners: Array<(state: State) => void> = []; 128 129 let memoryState: State = { toasts: [] }; 130 131 function dispatch(action: Action) { 132 memoryState = reducer(memoryState, action); 133 listeners.forEach((listener) => { 134 listener(memoryState); 135 }); 136 } 137 138 type Toast = Omit<ToasterToast, "id">; 139 140 function toast({ ...props }: Toast) { 141 const id = genId(); 142 143 const update = (props: ToasterToast) => 144 dispatch({ 145 type: "UPDATE_TOAST", 146 toast: { ...props, id }, 147 }); 148 const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); 149 150 dispatch({ 151 type: "ADD_TOAST", 152 toast: { 153 ...props, 154 id, 155 open: true, 156 onOpenChange: (open) => { 157 if (!open) dismiss(); 158 }, 159 }, 160 }); 161 162 return { 163 id: id, 164 dismiss, 165 update, 166 }; 167 } 168 169 function useToast() { 170 const [state, setState] = React.useState<State>(memoryState); 171 172 React.useEffect(() => { 173 listeners.push(setState); 174 return () => { 175 const index = listeners.indexOf(setState); 176 if (index > -1) { 177 listeners.splice(index, 1); 178 } 179 }; 180 }, [state]); 181 182 return { 183 ...state, 184 toast, 185 dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 186 }; 187 } 188 189 export { useToast, toast };