diff --git a/site/components/docs-layout.tsx b/site/components/docs-layout.tsx
index a88a85b..4269d50 100644
--- a/site/components/docs-layout.tsx
+++ b/site/components/docs-layout.tsx
@@ -75,6 +75,8 @@ export default function DocsLayout({ meta, children }) {
Guides
Styling
+ Multi Toaster
+
Releases
New in 2.0
diff --git a/site/pages/docs/multi-toaster.mdx b/site/pages/docs/multi-toaster.mdx
new file mode 100644
index 0000000..2f60969
--- /dev/null
+++ b/site/pages/docs/multi-toaster.mdx
@@ -0,0 +1,67 @@
+import Layout from '../../components/docs-layout';
+import toast, { Toaster } from 'react-hot-toast';
+
+export const meta = {
+ title: 'Multiple Toasters',
+};
+
+export default ({ children }) => {children};
+
+# Multiple Toasters
+
+React Hot Toast supports having multiple toaster instances in your app. This allows you to have different toast configurations and positions for different parts of your application.
+
+## Basic Usage
+
+You can create multiple toasters by giving each one a unique `toasterId`:
+
+```jsx
+
+
+
+
+// Create a toast in area 1
+toast('Notification for Area 1', { toasterId: 'area1' })
+```
+
+{/* Global toaster */}
+
+
= ({
toastOptions,
gutter,
children,
+ toasterId,
containerStyle,
containerClassName,
}) => {
- const { toasts, handlers } = useToaster(toastOptions);
+ const { toasts, handlers } = useToaster(toastOptions, toasterId);
return (
{
+interface State {
+ [toasterId: string]: ToasterState;
+}
+
+export const reducer = (state: ToasterState, action: Action): ToasterState => {
+ const { toastLimit } = state.settings;
+
switch (action.type) {
case ActionType.ADD_TOAST:
return {
...state,
- toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ toasts: [action.toast, ...state.toasts].slice(0, toastLimit),
};
case ActionType.UPDATE_TOAST:
@@ -120,17 +133,45 @@ export const reducer = (state: State, action: Action): State => {
}
};
-const listeners: Array<(state: State) => void> = [];
+const listeners: Array<
+ [toasterId: string, reducer: (state: ToasterState) => void]
+> = [];
-let memoryState: State = { toasts: [], pausedAt: undefined };
-
-export const dispatch = (action: Action) => {
- memoryState = reducer(memoryState, action);
- listeners.forEach((listener) => {
- listener(memoryState);
+const defaultToasterState: ToasterState = {
+ toasts: [],
+ pausedAt: undefined,
+ settings: {
+ toastLimit: TOAST_LIMIT,
+ },
+};
+let memoryState: State = {};
+
+export const dispatch = (action: Action, toasterId = DEFAULT_TOASTER_ID) => {
+ memoryState[toasterId] = reducer(
+ memoryState[toasterId] || defaultToasterState,
+ action
+ );
+ listeners.forEach(([id, listener]) => {
+ if (id === toasterId) {
+ listener(memoryState[toasterId]);
+ }
});
};
+export const dispatchAll = (action: Action) =>
+ Object.keys(memoryState).forEach((toasterId) => dispatch(action, toasterId));
+
+export const getToasterIdFromToastId = (toastId: string) =>
+ Object.keys(memoryState).find((toasterId) =>
+ memoryState[toasterId].toasts.some((t) => t.id === toastId)
+ );
+
+export const createDispatch =
+ (toasterId = DEFAULT_TOASTER_ID) =>
+ (action: Action) => {
+ dispatch(action, toasterId);
+ };
+
export const defaultTimeouts: {
[key in ToastType]: number;
} = {
@@ -141,23 +182,28 @@ export const defaultTimeouts: {
custom: 4000,
};
-export const useStore = (toastOptions: DefaultToastOptions = {}): State => {
- const [state, setState] = useState(memoryState);
- const initial = useRef(memoryState);
+export const useStore = (
+ toastOptions: DefaultToastOptions = {},
+ toasterId: string = DEFAULT_TOASTER_ID
+): ToasterState => {
+ const [state, setState] = useState(
+ memoryState[toasterId] || defaultToasterState
+ );
+ const initial = useRef(memoryState[toasterId]);
// TODO: Switch to useSyncExternalStore when targeting React 18+
useEffect(() => {
- if (initial.current !== memoryState) {
- setState(memoryState);
+ if (initial.current !== memoryState[toasterId]) {
+ setState(memoryState[toasterId]);
}
- listeners.push(setState);
+ listeners.push([toasterId, setState]);
return () => {
- const index = listeners.indexOf(setState);
+ const index = listeners.findIndex(([id]) => id === toasterId);
if (index > -1) {
listeners.splice(index, 1);
}
};
- }, []);
+ }, [toasterId]);
const mergedToasts = state.toasts.map((t) => ({
...toastOptions,
diff --git a/src/core/toast.ts b/src/core/toast.ts
index 2be7811..c8ef188 100644
--- a/src/core/toast.ts
+++ b/src/core/toast.ts
@@ -8,7 +8,13 @@ import {
resolveValue,
} from './types';
import { genId } from './utils';
-import { dispatch, ActionType } from './store';
+import {
+ createDispatch,
+ Action,
+ ActionType,
+ dispatchAll,
+ getToasterIdFromToastId,
+} from './store';
type Message = ValueOrFunction;
@@ -37,6 +43,11 @@ const createHandler =
(type?: ToastType): ToastHandler =>
(message, options) => {
const toast = createToast(message, type, options);
+
+ const dispatch = createDispatch(
+ toast.toasterId || getToasterIdFromToastId(toast.id)
+ );
+
dispatch({ type: ActionType.UPSERT_TOAST, toast });
return toast.id;
};
@@ -49,16 +60,53 @@ toast.success = createHandler('success');
toast.loading = createHandler('loading');
toast.custom = createHandler('custom');
-toast.dismiss = (toastId?: string) => {
- dispatch({
+/**
+ * Dismisses the toast with the given id. If no id is given, dismisses all toasts.
+ * The toast will transition out and then be removed from the DOM.
+ * Applies to all toasters, except when a `toasterId` is given.
+ */
+toast.dismiss = (toastId?: string, toasterId?: string) => {
+ const action: Action = {
type: ActionType.DISMISS_TOAST,
toastId,
- });
+ };
+
+ if (toasterId) {
+ createDispatch(toasterId)(action);
+ } else {
+ dispatchAll(action);
+ }
+};
+
+/**
+ * Dismisses all toasts.
+ */
+toast.dismissAll = (toasterId?: string) => toast.dismiss(undefined, toasterId);
+
+/**
+ * Removes the toast with the given id.
+ * The toast will be removed from the DOM without any transition.
+ */
+toast.remove = (toastId?: string, toasterId?: string) => {
+ const action: Action = {
+ type: ActionType.REMOVE_TOAST,
+ toastId,
+ };
+ if (toasterId) {
+ createDispatch(toasterId)(action);
+ } else {
+ dispatchAll(action);
+ }
};
-toast.remove = (toastId?: string) =>
- dispatch({ type: ActionType.REMOVE_TOAST, toastId });
+/**
+ * Removes all toasts.
+ */
+toast.removeAll = (toasterId?: string) => toast.remove(undefined, toasterId);
+/**
+ * Create a loading toast that will automatically updates with the promise.
+ */
toast.promise = (
promise: Promise | (() => Promise),
msgs: {
diff --git a/src/core/types.ts b/src/core/types.ts
index 764a5d7..2e668ee 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -34,6 +34,7 @@ export const resolveValue = (
export interface Toast {
type: ToastType;
id: string;
+ toasterId?: string;
message: ValueOrFunction;
icon?: Renderable;
duration?: number;
@@ -67,6 +68,7 @@ export type ToastOptions = Partial<
| 'style'
| 'position'
| 'iconTheme'
+ | 'toasterId'
| 'removeDelay'
>
>;
@@ -82,6 +84,7 @@ export interface ToasterProps {
gutter?: number;
containerStyle?: React.CSSProperties;
containerClassName?: string;
+ toasterId?: string;
children?: (toast: Toast) => React.ReactElement;
}
diff --git a/src/core/use-toaster.ts b/src/core/use-toaster.ts
index 5fb6e03..c97a0f1 100644
--- a/src/core/use-toaster.ts
+++ b/src/core/use-toaster.ts
@@ -1,43 +1,37 @@
-import { useEffect, useCallback } from 'react';
-import { dispatch, ActionType, useStore } from './store';
+import { useEffect, useCallback, useRef } from 'react';
+import { createDispatch, ActionType, useStore, dispatch } from './store';
import { toast } from './toast';
import { DefaultToastOptions, Toast, ToastPosition } from './types';
-const updateHeight = (toastId: string, height: number) => {
- dispatch({
- type: ActionType.UPDATE_TOAST,
- toast: { id: toastId, height },
- });
-};
-const startPause = () => {
- dispatch({
- type: ActionType.START_PAUSE,
- time: Date.now(),
- });
-};
-
-const toastTimeouts = new Map>();
-
export const REMOVE_DELAY = 1000;
-const addToRemoveQueue = (toastId: string, removeDelay = REMOVE_DELAY) => {
- if (toastTimeouts.has(toastId)) {
- return;
- }
-
- const timeout = setTimeout(() => {
- toastTimeouts.delete(toastId);
- dispatch({
- type: ActionType.REMOVE_TOAST,
- toastId: toastId,
- });
- }, removeDelay);
+export const useToaster = (
+ toastOptions?: DefaultToastOptions,
+ toasterId: string = 'default'
+) => {
+ const { toasts, pausedAt } = useStore(toastOptions, toasterId);
+ const toastTimeouts = useRef(
+ new Map>()
+ ).current;
+
+ const addToRemoveQueue = useCallback(
+ (toastId: string, removeDelay = REMOVE_DELAY) => {
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
- toastTimeouts.set(toastId, timeout);
-};
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: ActionType.REMOVE_TOAST,
+ toastId: toastId,
+ });
+ }, removeDelay);
-export const useToaster = (toastOptions?: DefaultToastOptions) => {
- const { toasts, pausedAt } = useStore(toastOptions);
+ toastTimeouts.set(toastId, timeout);
+ },
+ []
+ );
useEffect(() => {
if (pausedAt) {
@@ -59,19 +53,38 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => {
}
return;
}
- return setTimeout(() => toast.dismiss(t.id), durationLeft);
+ return setTimeout(() => toast.dismiss(t.id, toasterId), durationLeft);
});
return () => {
timeouts.forEach((timeout) => timeout && clearTimeout(timeout));
};
- }, [toasts, pausedAt]);
+ }, [toasts, pausedAt, toasterId]);
+
+ const dispatch = useCallback(createDispatch(toasterId), [toasterId]);
+
+ const startPause = useCallback(() => {
+ dispatch({
+ type: ActionType.START_PAUSE,
+ time: Date.now(),
+ });
+ }, [dispatch]);
+
+ const updateHeight = useCallback(
+ (toastId: string, height: number) => {
+ dispatch({
+ type: ActionType.UPDATE_TOAST,
+ toast: { id: toastId, height },
+ });
+ },
+ [dispatch]
+ );
const endPause = useCallback(() => {
if (pausedAt) {
dispatch({ type: ActionType.END_PAUSE, time: Date.now() });
}
- }, [pausedAt]);
+ }, [pausedAt, dispatch]);
const calculateOffset = useCallback(
(
@@ -104,8 +117,8 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => {
[toasts]
);
+ // Keep track of dismissed toasts and remove them after the delay
useEffect(() => {
- // Add dismissed toasts to remove queue
toasts.forEach((toast) => {
if (toast.dismissed) {
addToRemoveQueue(toast.id, toast.removeDelay);
@@ -118,7 +131,7 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => {
}
}
});
- }, [toasts]);
+ }, [toasts, addToRemoveQueue]);
return {
toasts,
diff --git a/test/toast.test.tsx b/test/toast.test.tsx
index a79d689..dea5c8e 100644
--- a/test/toast.test.tsx
+++ b/test/toast.test.tsx
@@ -341,3 +341,194 @@ test('"toast" can be called from useEffect hook', async () => {
await screen.findByText(/MyComponent finished/i);
expect(screen.queryByText(/Success toast/i)).toBeInTheDocument();
});
+
+describe('Multi-Toaster behavior', () => {
+ test('renders toasts in correct containers and dismisses them individually', () => {
+ render(
+ <>
+
+
+
+ >
+ );
+
+ // Show three toasts in three different toasters
+ act(() => {
+ toast.success('Default toaster message');
+ toast.error('Second toaster message', {
+ toasterId: 'second-toaster',
+ id: 'second-toast',
+ });
+ toast.loading('Third toaster message', { toasterId: 'third-toaster' });
+ });
+
+ const defaultContainer = document.querySelector('.default-toaster');
+ const secondContainer = document.querySelector('.second-toaster');
+ const thirdContainer = document.querySelector('.third-toaster');
+
+ // Ensure each toast is present and in the correct container
+ expect(defaultContainer).toContainElement(
+ screen.getByText('Default toaster message')
+ );
+ expect(secondContainer).toContainElement(
+ screen.getByText('Second toaster message')
+ );
+ expect(thirdContainer).toContainElement(
+ screen.getByText('Third toaster message')
+ );
+
+ // Dismiss only the toast in the second toaster
+ act(() => {
+ toast.dismiss('second-toast');
+ });
+
+ waitTime(REMOVE_DELAY);
+
+ expect(
+ screen.queryByText('Second toaster message')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText('Default toaster message')).toBeInTheDocument();
+ expect(screen.queryByText('Third toaster message')).toBeInTheDocument();
+
+ // Dismiss all toasts
+ act(() => {
+ toast.dismissAll();
+ });
+
+ waitTime(REMOVE_DELAY);
+
+ expect(
+ screen.queryByText('Default toaster message')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Second toaster message')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText('Third toaster message')).not.toBeInTheDocument();
+ });
+
+ test('updates a toast in a specific toaster without affecting others', () => {
+ render(
+ <>
+
+
+ >
+ );
+
+ let toastId: string;
+
+ // Create a loading toast in the second toaster
+ act(() => {
+ toastId = toast.loading('Please wait...', {
+ toasterId: 'updatable-toaster',
+ });
+ });
+
+ const secondContainer = document.querySelector('.updatable-toaster');
+ expect(secondContainer).toContainElement(
+ screen.getByText('Please wait...')
+ );
+
+ // Now update that toast to success
+ act(() => {
+ // Note that we are not providing a toasterId here
+ toast.success('Data saved!', {
+ id: toastId,
+ });
+ });
+
+ // Confirm the updated text
+ expect(screen.queryByText('Please wait...')).not.toBeInTheDocument();
+ expect(secondContainer).toContainElement(screen.getByText('Data saved!'));
+ });
+
+ test('dismisses all toasts from a specific toaster and leaves others intact', () => {
+ render(
+ <>
+
+
+ >
+ );
+
+ // Create one toast in each toaster
+ act(() => {
+ toast.success('Default toaster toast');
+ toast.success('Other toaster toast', { toasterId: 'other-toaster' });
+ });
+
+ // Ensure both appear
+ expect(screen.getByText('Default toaster toast')).toBeInTheDocument();
+ expect(screen.getByText('Other toaster toast')).toBeInTheDocument();
+
+ // Dismiss only the second toaster's toasts
+ act(() => {
+ toast.dismissAll('other-toaster');
+ });
+ waitTime(REMOVE_DELAY);
+
+ // The other toaster's toast should be gone, default remains
+ expect(screen.queryByText('Other toaster toast')).not.toBeInTheDocument();
+ expect(screen.queryByText('Default toaster toast')).toBeInTheDocument();
+ });
+
+ test('dismisses all toasts across all toasters with dismissAll', () => {
+ render(
+ <>
+
+
+ >
+ );
+
+ // Create one toast in each toaster
+ act(() => {
+ toast.success('Default toaster toast');
+ toast.error('Other toaster toast', { toasterId: 'other-toaster' });
+ });
+
+ // Dismiss every toast in all toasters
+ act(() => {
+ toast.dismissAll();
+ });
+ waitTime(REMOVE_DELAY);
+
+ // Both should be removed
+ expect(screen.queryByText('Default toaster toast')).not.toBeInTheDocument();
+ expect(screen.queryByText('Other toaster toast')).not.toBeInTheDocument();
+ });
+
+ test('removes toasts immediately when calling toast.remove()', () => {
+ render(
+ <>
+
+
+ >
+ );
+
+ act(() => {
+ toast.success('Removable toast #1', {
+ toasterId: 'instant-remove-toaster',
+ });
+ toast.error('Removable toast #2', { toasterId: 'another-toaster' });
+ });
+
+ expect(screen.queryByText('Removable toast #1')).toBeInTheDocument();
+ expect(screen.queryByText('Removable toast #2')).toBeInTheDocument();
+
+ act(() => {
+ toast.removeAll('instant-remove-toaster');
+ });
+
+ expect(screen.queryByText('Removable toast #1')).not.toBeInTheDocument();
+ expect(screen.queryByText('Removable toast #2')).toBeInTheDocument();
+ });
+});