5 minute read

toast-notification

Introduction

Toast notifications are a great way to provide feedback to users in a non-intrusive manner. While there are libraries like react-toastify you can easily integrate into your app, this guide is for those who want to actually learn how these things work behind the scenes. In this guide, I’ll walk you through how to implement a toast notification system with React’s createPortal. We’ll also ensure this implementation works seamlessly with Next.js’ new app router, which involves handling both client and server components.

Prerequisites

Step 1. Create the Toast Component

Create a simple toast component at app/components. For notification icons, I’m utilizing react-icons.

"use client";

import React, { useEffect } from 'react';
import { FaInfoCircle, FaCheckCircle } from "react-icons/fa";
import { MdOutlineWarning, MdOutlineError } from "react-icons/md";
import { ToastType } from '@/customTypes/toast';

const icons = {
  info: <FaInfoCircle />,
  warning: <MdOutlineWarning />,
  success: <FaCheckCircle />,
  error: <MdOutlineError />
};

interface ToastProps {
  id: string;
  title: string;
  message: string;
  type: ToastType;
  onRemove: (id: string) => void;
};

export default function Toast({ id, title, message, type, onRemove }: ToastProps){
  useEffect(() => {
    const timeout = setTimeout(() => {
      onRemove(id);
    }, getTimeout());

    return () => {
      clearTimeout(timeout);
    };
  }, [id, type, onRemove]);

  const getTimeout = () => {
    switch (type) {
      case 'warning':
        return 6500;
      case 'error':
        return 8000;
      default:
        return 5000;
    }
  };

  return (
    <div className={`toast toast-${type} flex gap-2 z-50 pointer-events-auto`} role="alert" onClick={() => onRemove(id)}>
      <span className="toast-icon text-2xl">{icons[type]}</span>
      <div className="toast-content">
        <h4 className="toast-title font-bold">{title}</h4>
        <p className="toast-message text-sm">{message}</p>
      </div>
    </div>
  );
};

Note that ToastType is defined as follows:

export type ToastType = 'info' | 'success' | 'warning' | 'error';

Add some basic styles for the toast notifications in styles/toast.css.

.toast-container {
  position: absolute;
  pointer-events: none;
  width: 100%;
  max-width: 400px;
  max-height: 100vh;
  scrollbar-width: 0.35em;
  padding: 1em;
  bottom: 0;
  right: 0;
  display: flex;
  flex-direction: column-reverse;
  gap: 4px;
}

.toast {
  position: relative;
  align-items: center;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  cursor: pointer;
  opacity: 0;
  animation: fadeIn 0.5s forwards;
  transition: box-shadow 0.2s ease-in-out, transform 0.2s ease-in-out;
  height: fit-content;
}

.toast:hover {
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
  transform: translateY(-2px);
}

.toast-info {
  background-color: #d9edf7;
  color: #31708f;
}

.toast-success {
  background-color: #dff0d8;
  color: #3c763d;
}

.toast-warning {
  background-color: #fcf8e3;
  color: #8a6d3b;
}

.toast-error {
  background-color: #f2dede;
  color: #a94442;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Step 2. Create the Toast Portal with createPortal

Import the Toast component we just created. We use React’s createPortal to render toast notifications at a root level so they appear above all other components. In this code, the root-level container is selected using querySelector. Read more about createPortal here: https://react.dev/reference/react-dom/createPortal.

Also note that the mounted variable is in place to check if the DOM is available before we try to query it. The mounted state is set to true after the initial render. For more details on using portals in Next.js refer to the offical example.

"use client";

import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import Toast from '@/components/toast';
import { ToastMessage } from '@/customTypes/toast';

interface ToastPortalProps {
  toasts: ToastMessage[],
  removeToast: (id: string) => void
}

export default function ToastPortal({
  toasts,
  removeToast
}: ToastPortalProps) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  return mounted ? createPortal(
    <div className="toast-container" aria-live="assertive">
      {toasts.map((toast: ToastMessage) => (
        <Toast
          key={toast.id}
          id={toast.id}
          title={toast.title}
          message={toast.message}
          type={toast.type}
          onRemove={removeToast}
        />
      ))}
    </div>,
    document.querySelector('#main')!
  ) : null;
};

The ToastMessage type is defined as follows:

export type ToastMessage = {
  id: string;
  title: string;
  message: string;
  type: ToastType;
};

Step 3. Create the Toast Context

Next, create a context to manage the toasts. Create a new file contexts/ToastContext.tsx:

"use client";

import React, { ReactNode, createContext, useContext, useState, useCallback } from 'react';
import ToastPortal from '@/components/toastPortal';
import { ToastMessage, ToastType } from '@/customTypes/toast';

export interface ToastContextProps {
  showInfo: (title: string, message: string) => void;
  showSuccess: (title: string, message: string) => void;
  showWarning: (title: string, message: string) => void;
  showError: (title: string, message: string) => void;
}

export const ToastContext = createContext<ToastContextProps | null>(null);

export const useToast = (): ToastContextProps => {
  const context = useContext(ToastContext);
  if (!context) {
    throw new Error('useToast must be used within a ToastProvider');
  }
  return context;
};

export default function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<ToastMessage[]>([]);

  const showToast = useCallback((title: string, message: string, type: ToastType) => {
    const id = Math.random().toString(36).substring(2, 9);
    setToasts(prev => [...prev, { id, title, message, type }]);
  }, []);

  const removeToast = useCallback((id: string) => {
    setToasts(prev => prev.filter(toast => toast.id !== id));
  }, []);

  const contextValue = {
    showInfo: (title: string, message: string) => showToast(title, message, 'info'),
    showSuccess: (title: string, message: string) => showToast(title, message, 'success'),
    showWarning: (title: string, message: string) => showToast(title, message, 'warning'),
    showError: (title: string, message: string) => showToast(title, message, 'error'),
  };

  return (
    <ToastContext.Provider value={contextValue}>
      {children}
      <ToastPortal toasts={toasts} removeToast={removeToast} />
    </ToastContext.Provider>
  );
}

Step 4. Integrate ToastProvider in Your Layout

import { Inter } from "next/font/google";
import ToastProvider from "@/contexts/toastContext";
import "@/styles/globals.css";
import "@/styles/toast.css";

const inter = Inter({ subsets: ["latin"] });

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <main role="main" id="main">
          <ToastProvider>
            {children}
          </ToastProvider>
        </main>
      </body>
    </html>
  );
}

Step 5. Use the Toast System

You can now use the toast system in any component. Here’s a simple example of a page that triggers toast notifications.

"use client";

import { useToast } from "./contexts/toastContext";

export default function Home() {
  const { showError, showInfo, showSuccess, showWarning } = useToast();

  return (
    <main className="main-container flex min-h-screen flex-col items-center justify-between p-24">
      <div className="flex flex-col gap-2">
        <button
          className="bg-blue-300 hover:bg-blue-400"
          onClick={() => showInfo('Info', 'This is an info message')}
        >
          Show Info Toast
        </button>
        <button
          className="bg-green-300 hover:bg-green-400"
          onClick={() => showSuccess('Success', 'This is a success message')}
        >
          Show Success Toast
        </button>
        <button
          className="bg-yellow-300 hover:bg-yellow-400"
          onClick={() => showWarning('Warning', 'This is a warning message')}
        >
          Show Warning Toast
        </button>
        <button
          className="bg-red-300 hover:bg-red-400"
          onClick={() => showError('Error', 'This is an error message')}
        >
          Show Error Toast
        </button>
      </div>
    </main>
  );
}

Conclusion

By following this guide, you’ve successfully set up a robust toast notification system in your Next.js project. Utilizing createPortal to render notifications ensures they are prominently displayed, enhancing the user experience. The full code for this project is available here. Feel free to explore and customize the code to fit your specific needs. Please share your thoughts, suggestions, or any issues you encounter. Happy coding!

Leave a comment