/ src / components / ui / use-toast.ts
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 };