react-hot-toast
Version:
Smoking hot React Notifications. Lightweight, customizable and beautiful by default.
187 lines (166 loc) • 4.05 kB
text/typescript
import { useEffect, useState, useRef } from 'react';
import { DefaultToastOptions, Toast, ToastType } from './types';
const TOAST_LIMIT = 20;
export enum ActionType {
ADD_TOAST,
UPDATE_TOAST,
UPSERT_TOAST,
DISMISS_TOAST,
REMOVE_TOAST,
START_PAUSE,
END_PAUSE,
}
type Action =
| {
type: ActionType.ADD_TOAST;
toast: Toast;
}
| {
type: ActionType.UPSERT_TOAST;
toast: Toast;
}
| {
type: ActionType.UPDATE_TOAST;
toast: Partial<Toast>;
}
| {
type: ActionType.DISMISS_TOAST;
toastId?: string;
}
| {
type: ActionType.REMOVE_TOAST;
toastId?: string;
}
| {
type: ActionType.START_PAUSE;
time: number;
}
| {
type: ActionType.END_PAUSE;
time: number;
};
interface State {
toasts: Toast[];
pausedAt: number | undefined;
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case ActionType.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case ActionType.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case ActionType.UPSERT_TOAST:
const { toast } = action;
return reducer(state, {
type: state.toasts.find((t) => t.id === toast.id)
? ActionType.UPDATE_TOAST
: ActionType.ADD_TOAST,
toast,
});
case ActionType.DISMISS_TOAST:
const { toastId } = action;
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
dismissed: true,
visible: false,
}
: t
),
};
case ActionType.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
case ActionType.START_PAUSE:
return {
...state,
pausedAt: action.time,
};
case ActionType.END_PAUSE:
const diff = action.time - (state.pausedAt || 0);
return {
...state,
pausedAt: undefined,
toasts: state.toasts.map((t) => ({
...t,
pauseDuration: t.pauseDuration + diff,
})),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [], pausedAt: undefined };
export const dispatch = (action: Action) => {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
};
export const defaultTimeouts: {
[key in ToastType]: number;
} = {
blank: 4000,
error: 4000,
success: 2000,
loading: Infinity,
custom: 4000,
};
export const useStore = (toastOptions: DefaultToastOptions = {}): State => {
const [state, setState] = useState<State>(memoryState);
const initial = useRef(memoryState);
// TODO: Switch to useSyncExternalStore when targeting React 18+
useEffect(() => {
if (initial.current !== memoryState) {
setState(memoryState);
}
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, []);
const mergedToasts = state.toasts.map((t) => ({
...toastOptions,
...toastOptions[t.type],
...t,
removeDelay:
t.removeDelay ||
toastOptions[t.type]?.removeDelay ||
toastOptions?.removeDelay,
duration:
t.duration ||
toastOptions[t.type]?.duration ||
toastOptions?.duration ||
defaultTimeouts[t.type],
style: {
...toastOptions.style,
...toastOptions[t.type]?.style,
...t.style,
},
}));
return {
...state,
toasts: mergedToasts,
};
};