Skip to main content
Back to Blog
Tutorials
4 min read
December 14, 2024

How to Build a Notification Center in React

Build a complete notification center with a bell icon badge, dropdown panel, read/unread states, and real-time updates.

Ryel Banfield

Founder & Lead Developer

A notification center keeps users informed about important events. Here is how to build one with a dropdown panel and real-time updates.

Notification Types

// types/notifications.ts
export type NotificationType = "info" | "success" | "warning" | "error" | "mention" | "update";

export interface Notification {
  id: string;
  type: NotificationType;
  title: string;
  body: string;
  read: boolean;
  createdAt: string;
  actionUrl?: string;
  actor?: {
    name: string;
    avatar?: string;
  };
}

Notification Store

"use client";

import { createContext, useContext, useCallback, useReducer, useEffect } from "react";
import type { Notification } from "@/types/notifications";

interface NotificationState {
  notifications: Notification[];
  unreadCount: number;
  loading: boolean;
}

type Action =
  | { type: "SET_NOTIFICATIONS"; payload: Notification[] }
  | { type: "ADD_NOTIFICATION"; payload: Notification }
  | { type: "MARK_READ"; payload: string }
  | { type: "MARK_ALL_READ" }
  | { type: "REMOVE"; payload: string }
  | { type: "SET_LOADING"; payload: boolean };

function reducer(state: NotificationState, action: Action): NotificationState {
  switch (action.type) {
    case "SET_NOTIFICATIONS":
      return {
        ...state,
        notifications: action.payload,
        unreadCount: action.payload.filter((n) => !n.read).length,
        loading: false,
      };
    case "ADD_NOTIFICATION":
      return {
        ...state,
        notifications: [action.payload, ...state.notifications],
        unreadCount: state.unreadCount + (action.payload.read ? 0 : 1),
      };
    case "MARK_READ": {
      const notifications = state.notifications.map((n) =>
        n.id === action.payload ? { ...n, read: true } : n
      );
      return {
        ...state,
        notifications,
        unreadCount: notifications.filter((n) => !n.read).length,
      };
    }
    case "MARK_ALL_READ":
      return {
        ...state,
        notifications: state.notifications.map((n) => ({ ...n, read: true })),
        unreadCount: 0,
      };
    case "REMOVE":
      return {
        ...state,
        notifications: state.notifications.filter((n) => n.id !== action.payload),
        unreadCount: state.notifications.filter(
          (n) => n.id !== action.payload && !n.read
        ).length,
      };
    case "SET_LOADING":
      return { ...state, loading: action.payload };
    default:
      return state;
  }
}

interface NotificationContextValue extends NotificationState {
  markRead: (id: string) => void;
  markAllRead: () => void;
  remove: (id: string) => void;
  refresh: () => void;
}

const NotificationContext = createContext<NotificationContextValue | null>(null);

export function NotificationProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, {
    notifications: [],
    unreadCount: 0,
    loading: true,
  });

  const fetchNotifications = useCallback(async () => {
    dispatch({ type: "SET_LOADING", payload: true });
    const res = await fetch("/api/notifications");
    const data = await res.json();
    dispatch({ type: "SET_NOTIFICATIONS", payload: data });
  }, []);

  useEffect(() => {
    fetchNotifications();
  }, [fetchNotifications]);

  // SSE for real-time updates
  useEffect(() => {
    const eventSource = new EventSource("/api/notifications/stream");

    eventSource.addEventListener("notification", (e) => {
      const notification = JSON.parse(e.data) as Notification;
      dispatch({ type: "ADD_NOTIFICATION", payload: notification });
    });

    eventSource.onerror = () => {
      eventSource.close();
      // Reconnect after delay
      setTimeout(() => fetchNotifications(), 5000);
    };

    return () => eventSource.close();
  }, [fetchNotifications]);

  const markRead = useCallback(async (id: string) => {
    dispatch({ type: "MARK_READ", payload: id });
    await fetch(`/api/notifications/${id}/read`, { method: "PATCH" });
  }, []);

  const markAllRead = useCallback(async () => {
    dispatch({ type: "MARK_ALL_READ" });
    await fetch("/api/notifications/read-all", { method: "PATCH" });
  }, []);

  const remove = useCallback(async (id: string) => {
    dispatch({ type: "REMOVE", payload: id });
    await fetch(`/api/notifications/${id}`, { method: "DELETE" });
  }, []);

  return (
    <NotificationContext.Provider
      value={{ ...state, markRead, markAllRead, remove, refresh: fetchNotifications }}
    >
      {children}
    </NotificationContext.Provider>
  );
}

export function useNotifications() {
  const ctx = useContext(NotificationContext);
  if (!ctx) throw new Error("useNotifications must be used within NotificationProvider");
  return ctx;
}

Bell Icon with Badge

"use client";

import { useNotifications } from "./NotificationProvider";
import { useState, useRef, useEffect } from "react";
import { NotificationPanel } from "./NotificationPanel";

export function NotificationBell() {
  const { unreadCount } = useNotifications();
  const [open, setOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handler = (e: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, []);

  return (
    <div ref={containerRef} className="relative">
      <button
        onClick={() => setOpen(!open)}
        className="relative p-2 rounded-lg hover:bg-muted"
        aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
      >
        <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
          />
        </svg>
        {unreadCount > 0 && (
          <span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1">
            {unreadCount > 99 ? "99+" : unreadCount}
          </span>
        )}
      </button>

      {open && <NotificationPanel onClose={() => setOpen(false)} />}
    </div>
  );
}

Notification Panel

"use client";

import { useNotifications } from "./NotificationProvider";
import type { Notification } from "@/types/notifications";

interface NotificationPanelProps {
  onClose: () => void;
}

export function NotificationPanel({ onClose }: NotificationPanelProps) {
  const { notifications, loading, markRead, markAllRead, remove, unreadCount } =
    useNotifications();

  return (
    <div className="absolute right-0 top-full mt-2 w-80 bg-popover border rounded-lg shadow-lg z-50 overflow-hidden">
      {/* Header */}
      <div className="flex items-center justify-between px-4 py-3 border-b">
        <h3 className="font-semibold text-sm">Notifications</h3>
        {unreadCount > 0 && (
          <button
            onClick={markAllRead}
            className="text-xs text-primary hover:underline"
          >
            Mark all read
          </button>
        )}
      </div>

      {/* List */}
      <div className="max-h-[400px] overflow-y-auto">
        {loading ? (
          <div className="p-4 space-y-3">
            {Array.from({ length: 3 }).map((_, i) => (
              <div key={i} className="h-14 bg-muted rounded animate-pulse" />
            ))}
          </div>
        ) : notifications.length === 0 ? (
          <div className="p-8 text-center text-sm text-muted-foreground">
            No notifications yet
          </div>
        ) : (
          notifications.map((notification) => (
            <NotificationItem
              key={notification.id}
              notification={notification}
              onRead={markRead}
              onRemove={remove}
            />
          ))
        )}
      </div>

      {/* Footer */}
      {notifications.length > 0 && (
        <div className="border-t px-4 py-2">
          <a
            href="/notifications"
            className="text-xs text-primary hover:underline"
            onClick={onClose}
          >
            View all notifications
          </a>
        </div>
      )}
    </div>
  );
}

function NotificationItem({
  notification,
  onRead,
  onRemove,
}: {
  notification: Notification;
  onRead: (id: string) => void;
  onRemove: (id: string) => void;
}) {
  const typeIcons: Record<string, string> = {
    info: "bg-blue-100 text-blue-600",
    success: "bg-green-100 text-green-600",
    warning: "bg-yellow-100 text-yellow-600",
    error: "bg-red-100 text-red-600",
    mention: "bg-purple-100 text-purple-600",
    update: "bg-gray-100 text-gray-600",
  };

  const timeAgo = getTimeAgo(new Date(notification.createdAt));

  return (
    <div
      className={`flex gap-3 px-4 py-3 hover:bg-muted/50 cursor-pointer group ${
        !notification.read ? "bg-primary/5" : ""
      }`}
      onClick={() => {
        if (!notification.read) onRead(notification.id);
        if (notification.actionUrl) window.location.href = notification.actionUrl;
      }}
      role="button"
      tabIndex={0}
    >
      {/* Icon */}
      <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${typeIcons[notification.type]}`}>
        {notification.actor?.avatar ? (
          <img
            src={notification.actor.avatar}
            alt=""
            className="w-8 h-8 rounded-full"
          />
        ) : (
          <span className="text-xs font-bold">
            {notification.type[0].toUpperCase()}
          </span>
        )}
      </div>

      {/* Content */}
      <div className="flex-1 min-w-0">
        <p className="text-sm leading-snug">
          {notification.actor && (
            <span className="font-medium">{notification.actor.name} </span>
          )}
          {notification.title}
        </p>
        {notification.body && (
          <p className="text-xs text-muted-foreground mt-0.5 truncate">
            {notification.body}
          </p>
        )}
        <span className="text-xs text-muted-foreground">{timeAgo}</span>
      </div>

      {/* Unread dot / Remove */}
      <div className="flex items-center">
        {!notification.read ? (
          <div className="w-2 h-2 rounded-full bg-primary" />
        ) : (
          <button
            onClick={(e) => {
              e.stopPropagation();
              onRemove(notification.id);
            }}
            className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-1"
            aria-label="Remove notification"
          >
            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        )}
      </div>
    </div>
  );
}

function getTimeAgo(date: Date): string {
  const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  if (seconds < 60) return "just now";
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
  if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
  return date.toLocaleDateString();
}

Need In-App Notifications?

We build real-time notification systems for web and mobile apps. Contact us to discuss your requirements.

notificationsbell iconReactreal-timetutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles