Oct 11, 2024
•
9 min read
I used to hate implementing toast notifications. Every project had some weird setup where you had to import a context, wrap your component, pass functions around... it was annoying.
Then I figured out how to make a toast utility that you can literally just import and call from anywhere. No context, no providers, no prop drilling. Just toast.success('it worked') and you're done.
Using sonner because it's the best toast library and I'll fight anyone who disagrees. Also Material-UI for styling because that's what we use at work.
npm install sonner @mui/material @mui/icons-material @emotion/react @emotion/styled
Made a custom snackbar with MUI styling:
// components/toast/BaseSnackbar.tsx
'use client';
import { SnackbarContent, Theme } from '@mui/material';
import {
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Warning as WarningIcon,
} from '@mui/icons-material';
import { toast as sonnerToast } from 'sonner';
interface ToastProps {
id: string | number;
title: string;
variant: 'success' | 'error' | 'warning';
icon?: React.ReactNode;
closeHandler?: () => void;
actionText?: string;
actionHandler?: () => void;
}
export const BaseSnackbar = (props: ToastProps) => {
const getStyle = (theme: Theme) => {
switch (props.variant) {
case 'success':
return { backgroundColor: theme.palette.success.main, color: '#fff' };
case 'error':
return { backgroundColor: theme.palette.error.main, color: '#fff' };
case 'warning':
return { backgroundColor: theme.palette.warning.main, color: '#fff' };
}
};
const getIcon = () => {
if (props.icon) return props.icon;
switch (props.variant) {
case 'success': return <SuccessIcon />;
case 'error': return <ErrorIcon />;
case 'warning': return <WarningIcon />;
}
};
return (
<SnackbarContent
sx={theme => ({ fontWeight: 500, minWidth: 300, ...getStyle(theme) })}
message={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{getIcon()}
<span>{props.title}</span>
</div>
}
action={
<>
{props.actionText && (
<button
onClick={() => {
props.actionHandler?.();
sonnerToast.dismiss(props.id);
}}
style={{
background: 'none',
border: 'none',
color: 'white',
cursor: 'pointer',
fontWeight: 600
}}
>
{props.actionText}
</button>
)}
<button
onClick={() => {
sonnerToast.dismiss(props.id);
props.closeHandler?.();
}}
style={{
background: 'none',
border: 'none',
color: 'white',
cursor: 'pointer',
fontSize: '20px'
}}
>
✕
</button>
</>
}
/>
);
};
This is the part that makes it work everywhere:
// utils/toast.tsx
'use client';
import { toast as sonnerToast } from 'sonner';
import { BaseSnackbar } from '@/components/toast/BaseSnackbar';
interface ToastOptions {
icon?: React.ReactNode;
closeHandler?: () => void;
actionText?: string;
actionHandler?: () => void;
position?: 'top-right' | 'top-center' | 'bottom-right' | 'bottom-center';
}
export const toast = {
success: (message: string, options?: ToastOptions) =>
sonnerToast.custom(
id => <BaseSnackbar id={id} title={message} variant="success" {...options} />,
{ position: options?.position || 'bottom-center' }
),
error: (message: string, options?: ToastOptions) =>
sonnerToast.custom(
id => <BaseSnackbar id={id} title={message} variant="error" {...options} />,
{ position: options?.position || 'bottom-center' }
),
warning: (message: string, options?: ToastOptions) =>
sonnerToast.custom(
id => <BaseSnackbar id={id} title={message} variant="warning" {...options} />,
{ position: options?.position || 'bottom-center' }
)
};
// app/layout.tsx
import { Toaster } from 'sonner';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Toaster richColors />
</body>
</html>
);
}
In a component:
'use client';
import { toast } from '@/utils/toast';
export const UserForm = () => {
const handleSubmit = async (data) => {
try {
await saveUser(data);
toast.success('User saved!');
} catch (error) {
toast.error('Failed to save user');
}
};
return <form onSubmit={handleSubmit}>...</form>;
};
In a custom hook:
import { toast } from '@/utils/toast';
export const useUserMutation = () => {
const mutation = useMutation({
mutationFn: saveUser,
onSuccess: () => toast.success('Saved!'),
onError: () => toast.error('Failed')
});
return mutation;
};
In a utility function:
import { toast } from '@/utils/toast';
export const deleteUser = async (id: number) => {
try {
await api.delete(`/users/${id}`);
toast.success('User deleted');
} catch (error) {
toast.error('Delete failed');
}
};
This is where it gets fun. You can add action buttons:
toast.success('File uploaded!', {
actionText: 'View',
actionHandler: () => router.push('/files/123')
});
toast.error('Connection lost', {
actionText: 'Retry',
actionHandler: async () => await retryConnection()
});
I use this for undo actions all the time:
const handleDelete = async (id: number) => {
await deleteItem(id);
toast.success('Item deleted', {
actionText: 'Undo',
actionHandler: async () => {
await restoreItem(id);
toast.success('Item restored!');
}
});
};
// Less important stuff
toast.info('New message', { position: 'top-right' });
// Important actions
toast.success('Payment processed'); // bottom-center by default
For longer operations:
const handleUpload = async (file: File) => {
const toastId = toast.info('Uploading...');
try {
await uploadFile(file);
sonnerToast.dismiss(toastId);
toast.success('Upload complete!');
} catch (error) {
sonnerToast.dismiss(toastId);
toast.error('Upload failed');
}
};
Or use sonner's promise helper:
import { toast as sonnerToast } from 'sonner';
await sonnerToast.promise(
saveData(),
{
loading: 'Saving...',
success: 'Saved!',
error: 'Failed to save'
}
);
const handleSubmit = async (formData) => {
try {
setLoading(true);
await submitForm(formData);
toast.success('Form submitted!', {
actionText: 'View',
actionHandler: () => router.push('/submissions')
});
reset();
} catch (error) {
toast.error(error.message || 'Something went wrong');
} finally {
setLoading(false);
}
};
I make these for common operations:
// utils/userToasts.ts
import { toast } from './toast';
export const userToasts = {
created: () => toast.success('User created'),
updated: () => toast.success('User updated'),
deleted: () => toast.success('User deleted'),
error: (action: string) => toast.error(`Failed to ${action} user`)
};
// Usage
userToasts.created();
Saves time when you're doing the same operations over and over.
No setup per component: Just import and call. No providers, no context, no nothing.
Works everywhere: Components, hooks, utils, server actions - doesn't matter.
Type-safe: TypeScript knows what options you can pass.
Customizable: Want a custom icon? Position? Action button? All there.
MUI styled: Matches the rest of our app without extra work.
Don't spam toasts: In loops or rapid-fire operations, you'll get 50 toasts. Be smart about when you call it.
Server components: The toast utility itself is client-side. You can call it from server actions, but not server components directly.
Loading toasts: Always dismiss them. I've left loading toasts up by accident more times than I want to admit.
This setup has saved me so much time. No more:
Just import it, call it, done.
If you're still using some complicated toast setup with providers and contexts, try this instead. Way simpler.
© 2025 Lucas Nogueira. All rights reserved.
Built with Next.js & Material-UI