Live Notifications — Real-Time Push from FastAPI to React

Live notifications are a key feature of the blog application: when someone likes your post or leaves a comment, you should see a bell icon light up immediately — not on the next page load. The architecture is straightforward: FastAPI’s ConnectionManager tracks each user’s active WebSocket connections; the HTTP endpoints that create likes and comments call manager.send_to_user() after saving to the database; React listens on the WebSocket and updates the notification badge in real time.

useNotifications Hook

// src/hooks/useNotifications.js
import { useState, useCallback } from "react";
import { useWebSocket }          from "@/hooks/useWebSocket";
import { useAuthStore }          from "@/stores/authStore";

export function useNotifications() {
    const isLoggedIn                       = useAuthStore((s) => Boolean(s.user));
    const [notifications, setNotifications] = useState([]);
    const [unreadCount,   setUnreadCount]   = useState(0);

    const handleMessage = useCallback((data) => {
        switch (data.type) {
            case "notification":
                setNotifications((prev) => [data, ...prev].slice(0, 20));
                setUnreadCount((c) => c + 1);
                break;
            case "notification_read":
                setUnreadCount(0);
                break;
            case "pong":
                // heartbeat response — ignore
                break;
        }
    }, []);

    const { status, send } = useWebSocket("/ws/notifications", {
        onMessage: handleMessage,
        enabled:   isLoggedIn,
    });

    function markAllRead() {
        setUnreadCount(0);
        send({ type: "mark_read" });
    }

    return { notifications, unreadCount, markAllRead, connectionStatus: status };
}
Note: Notifications are stored only in React state (not persisted between page loads) for the basic implementation. For a production application, fetch recent notifications from GET /api/notifications on mount to show existing unread notifications from before the current session, then layer real-time WebSocket updates on top. The initial HTTP fetch ensures the user sees notifications that arrived while they were offline; the WebSocket keeps the count current while they are online.
Tip: Slice the notifications array to a maximum length (e.g., 20) to prevent unbounded memory growth in long-running sessions: setNotifications(prev => [data, ...prev].slice(0, 20)). A user who leaves a tab open for hours should not have thousands of notification objects in memory. For the full notification history, rely on the HTTP endpoint and database pagination — the in-memory array is just a live preview of recent activity.
Warning: The FastAPI WebSocket notification endpoint must only push to the authenticated user’s own connection — never broadcast to all users. Verify the user ID from the JWT token matches the notification recipient before calling manager.send_to_user(recipient_id, data). A bug that broadcasts notifications to all connected users would leak private information (new comment content, like details) to every logged-in user simultaneously.

Notification Bell Component

import { useState, useRef, useEffect } from "react";
import { useNotifications }             from "@/hooks/useNotifications";

function NotificationBell() {
    const { notifications, unreadCount, markAllRead } = useNotifications();
    const [isOpen, setIsOpen] = useState(false);
    const dropdownRef         = useRef(null);

    // Close dropdown when clicking outside
    useEffect(() => {
        function handleClickOutside(e) {
            if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
                setIsOpen(false);
            }
        }
        if (isOpen) document.addEventListener("mousedown", handleClickOutside);
        return () => document.removeEventListener("mousedown", handleClickOutside);
    }, [isOpen]);

    function handleToggle() {
        setIsOpen((prev) => !prev);
        if (!isOpen && unreadCount > 0) markAllRead();
    }

    return (
        <div className="relative" ref={dropdownRef}>
            {/* Bell button with badge */}
            <button onClick={handleToggle}
                    className="relative p-2 rounded-full hover:bg-gray-100"
                    aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}>
                🔔
                {unreadCount > 0 && (
                    <span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white
                                     text-xs w-5 h-5 rounded-full flex items-center justify-center
                                     font-bold">
                        {unreadCount > 9 ? "9+" : unreadCount}
                    </span>
                )}
            </button>

            {/* Dropdown */}
            {isOpen && (
                <div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-lg
                                border border-gray-100 z-50">
                    <div className="px-4 py-3 border-b border-gray-100 font-semibold text-sm">
                        Notifications
                    </div>
                    <ul className="max-h-80 overflow-y-auto divide-y divide-gray-50">
                        {notifications.length === 0 ? (
                            <li className="px-4 py-6 text-center text-sm text-gray-400">
                                No new notifications
                            </li>
                        ) : (
                            notifications.map((n, i) => (
                                <li key={i} className="px-4 py-3 text-sm hover:bg-gray-50">
                                    <p className="font-medium">{n.message ?? n.event}</p>
                                    {n.post_title && (
                                        <p className="text-gray-500 text-xs mt-0.5 truncate">
                                            {n.post_title}
                                        </p>
                                    )}
                                </li>
                            ))
                        )}
                    </ul>
                </div>
            )}
        </div>
    );
}

FastAPI Side — Pushing Notifications from HTTP Endpoints

# In app/routers/comments.py
@router.post("/posts/{post_id}/comments", response_model=CommentResponse, status_code=201)
async def create_comment(
    post_id:      int,
    data:         CommentCreate,
    db:           Session          = Depends(get_db),
    current_user: User             = Depends(get_current_user),
):
    comment = create_comment_service(db, post_id, current_user.id, data)

    post = db.get(Post, post_id)
    # Push real-time notification to post author (if not self-commenting)
    if post and post.author_id != current_user.id:
        await manager.send_to_user(post.author_id, {
            "type":       "notification",
            "event":      "new_comment",
            "message":    f"{current_user.name} commented on your post",
            "post_id":    post_id,
            "post_title": post.title,
        })

    return comment

Common Mistakes

Mistake 1 — Not fetching existing notifications on mount

❌ Wrong — user sees empty notification list on page load even if they have unread notifications from before the session.

✅ Correct — fetch GET /api/notifications?unread=true on mount to initialise the list.

Mistake 2 — Broadcasting notifications to all users

❌ Wrong — sending to all WebSocket connections instead of just the post author.

✅ Correct — always use manager.send_to_user(recipient_id, data), never manager.broadcast(data) for private notifications.

🧠 Test Yourself

User A has two browser tabs open. Both tabs are connected to the notification WebSocket. User B likes User A’s post. How many notification toasts should User A see?