🦁

Next.jsのアプリにWebsocketを使った通知機能をつけてみた

2025/03/06に公開

目的

Next.jsを使って自主開発をしているwebアプリケーションに通知機能をつけてみようと思い、その実装を備忘録として残しておこうと思います。大きく分けて以下の二つです。

  1. websocketを使用したサーバークライアント間の通信の実装
  2. 通知をうけとり表示するフロントエンド側の通信の実装
  3. トースト通知を行うフロントエンドコンポーネントの実装

通知機能はこんなことに使う予定です

  • 投稿を全ユーザーに通知する
  • バッチ処理の終了を通知する

使っているもの

実行環境

  • Next.js v15.1.5
  • Tailwind CSS v3.4.1
  • socket.io v4.8.1

技術

  • Websocket

https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API

サーバー側

Websocketサーバー

Next.jsのカスタムサーバーを作ることでWebsocketを使用します。

server.ts
import {createServer} from "node:http";
import {Server} from "socket.io";
import {parse} from "node:url";
import next from "next";

declare global {
    var io: Server | undefined;
}
const dev = process.env.NODE_ENV !== "production";
const app = next({dev, turbo:true});
const handle = app.getRequestHandler();


app.prepare().then(() => {
    // HTTPサーバーを作成
    const server = createServer((req, res) => {
        if (!req.url) return;
        const parsedUrl = parse(req.url, true);
        handle(req, res, parsedUrl);
    });

    const io = new Server(server, {
        cors:{
            origin: "*", 
            methods: ["GET", "POST"]
        }
    })

    global.io = io;

    // 接続時の処理:
    io.on("connection", (socket) => {
        console.log("connected");

        // ユーザーごとに個別のルームに入る
        socket.on("joinRoom", (userId: string) =>{
            socket.join(userId);
            console.log(`User ${userId} joined`);
            console.log(`Rooms: ${io.sockets.adapter.rooms}`);
        })

        // 切断時の処理
        socket.on("disconnect", () => {
            console.log("disconnected");
        })
    })
    server.listen(process.env.NEXT_WEBSOCKET_PORT, () => {
        console.log(`> Ready on http://localhost:${process.env.NEXT_WEBSOCKET_PORT}`);
    })
    
})

実行するには

npx ts-node --project tsconfig.server.json <server-file-path>

クライアント側

websocket用のコンポーネントの実装

一つ目はクライアント側でアプリを起動した際にwebsocket通信を行うhookです。全体への通知と個別のユーザーへの通知を行いたいため、ユニークな値であるuserIDを受け取り通信を開始します。

useSocket.ts
"use client";

import {useCallback, useEffect, useState} from "react";
import {io} from "socket.io-client";
import {useAtom} from "jotai";
import { Socket } from "socket.io-client";
import {atom} from "jotai";

const socketAtom = atom<Socket | null>(null);

// 接続を確立する関数
const connectSocket = (userId: string) => {
    const newSocket = io(process.env.NEXT_PUBLIC_BASE_URL,{
        reconnection: true,
        reconnectionAttempts: 3,
    })

    newSocket.on("connect", () => {
        // ユーザーごとのルームに入る
        newSocket.emit("joinRoom", userId);
    })

    return newSocket;
}


/**
 * サーバーとの通信を管理するためのカスタムフック
 * @param userId ユーザーID
 * @returns socket: Socket, connectSocket: () => void, disconnectSocket: () => void
 */

export const useSocket = (userId: string) => {
    // Socketのインスタンスをグローバルに管理する
    const [socket, setSocket] = useAtom(socketAtom)

    // サーバーへの接続を確立する関数
    const connectSocketWithUser = useCallback(() => {
        setSocket(connectSocket(userId))
        
    }, [userId, setSocket])

    const disconnectSocket = useCallback(() => {
        if (socket){
        socket?.disconnect();
        setSocket(null);
        }
    }, [socket, setSocket])

    // アンマウントされた際に接続を切断する
    useEffect(() => {
        return () => {
            disconnectSocket();
        }
    }, [disconnectSocket])


    return {
        socket,
        connectSocket: connectSocketWithUser,
        disconnectSocket
    }

}

<!-- 使い方としては

const {socket, connectSocket, dieconnectSocket} = useSocket(userId);

-->

通知を受け取るイベントリスナーの実装

NotificationListener.tsx
"use client";

import { useSocket } from "@/hooks/useSocket";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/useToast";
import { useSession } from "next-auth/react";

export const NotificationListener = () => {
    const {data: session, status} = useSession();
    const [userId, setUserId] = useState<string>("default");
    useEffect(() =>{
        if(session?.user?.id){
            console.log("session", session)
            setUserId(session.user.id);
        }
    }, [session])
    
    const {socket, connectSocket, disconnectSocket} = useSocket(userId);
    const showToast = useToast();

    // 接続管理: 特定の処理の間だけ接続を確立したい場合はその状態を使用する. デフォルトは常に通信を確立する
    useEffect(() => {
        if (!socket){
            connectSocket();
        }
    },[socket, connectSocket])



    const handleNormalNotification = () =>{
        console.log("notification received");
        showToast({text: "You have a new notification!", type: "normal", duration: 5000});
    }

    const handleErrorNotification = () => {
        console.log("error notification received");
        showToast({text: "Failed to generate image.", type: "error", duration: 5000});
    }

    const handleSuccessNotification = () => {
        console.log("success notification received");
        showToast({text: "Image generated successfully!", type: "success", duration: 10000});
    }

    const handlePostNotification = (arg1: string) => {
        console.log("new post was created");
        showToast({text: "New post was created!", type: "normal", duration: 5000});
    }
    // 
    useEffect(() =>{
        if (!socket) return;
        socket.on("notification to all user", handleNormalNotification);
        socket.on("normal notification", handleNormalNotification);
        socket.on("error notification", handleErrorNotification);
        socket.on("success notification", handleSuccessNotification);
        socket.on("new post was created", (arg1) => handlePostNotification(arg1));
        
        return () =>{
            socket.off("notification to all user", handleNormalNotification);
            socket.off("normal notification", handleNormalNotification);
            socket.off("error notification", handleErrorNotification);
            socket.off("success notification", handleSuccessNotification);
            socket.off("new post was created", handlePostNotification);
        }
    }, [socket, disconnectSocket])

    return null;
}

表示するUIについて

通知の仕方としてトースト通知を行います。アプリのどのページでも通知を共有するために、グローバルな状態管理をします。

トーストのProviderの実装

ToastProvider.tsx
"use client";
import React, { useState, useEffect, useContext, createContext, use, useRef } from 'react';
import ReactDOM from 'react-dom'
import { motion, AnimatePresence } from "framer-motion";
import { NotificationToast } from '@/components/notification/NotificationToast';

type ToastTypes = "normal" | "error" | "success";
type ToastProps = {
  id: number;
  text: string;
  duration: number;
  toastType: ToastTypes;
  onClose: () => void;
  // args?: any;
}

const ToastContext = createContext(({}: {text:string; type?:ToastTypes; duration:number}) => {});
ToastContext.displayName = "ToastContext";

export const useToast = () => {
    return useContext(ToastContext);
}



// ToastのContextを提供する
export const ToastProvider: React.FC<{children: React.ReactNode}> = ({children}) => {

    const [toasts, setToasts] = useState<ToastProps[]>([]);
    const showToast = ({text, toastType, duration=10000}: {text:string; toastType?:ToastTypes; duration:number}) => {
        const id = Date.now();
        const onClose = () => {setToasts((prev) => prev.filter((toast) => toast.id !== id))};
        setToasts((prev) => [...prev, {id, text, toastType: toastType || "normal", duration, onClose: onClose}]);
        setTimeout(() => {
            onClose();
        }, duration);
    }

   
    return (
        <ToastContext.Provider value={showToast}>
            {children}
             
              <div className="absolute top-0 w-full py-4 space-y-4 mx-auto">
                <AnimatePresence>
                  {toasts.map((toast) => (
                    <Toast key={toast.id} id={toast.id} text={toast.text} duration={toast.duration} toastType={toast.toastType} onClose={toast.onClose}/>
                  ))}
                </AnimatePresence>
              </div>
        </ToastContext.Provider> 
    )
}

const Toast = ({ id, text, duration, toastType, onClose}:ToastProps) => {

    const getToastStyle = () => {
      switch (toastType) {
        case 'success':
          return 'bg-green-500 text-white';
        case 'error':
          return 'bg-red-500 text-white';
        default:
          return 'bg-gray-500 text-white';
      }
    };
    
      return (
        <motion.div
                key={id}
                initial={{ opacity: 0, y: -10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -10 }}
                transition={{ duration: 0.5, ease: "easeInOut" }}
                className={`${getToastStyle()}`}
                layout
              >
                <NotificationToast text={text} duration={duration} onClose={onClose}/>
              </motion.div>
      );
    };

トーストのUI

import { useEffect, useState } from "react";
import { CloseIcon } from "../Icon/CloseIcon";


export const NotificationToast = ({text, duration, onClose}: {text:string; duration:number; onClose:() => void}) => {
    const [progress, setProgress] = useState("100%");

    useEffect(() => {
        setTimeout(() => {
        onClose();
        }, duration);

        // 進捗バーのアニメーション開始
        setTimeout(() => {
        setProgress("0%");
        }, 10);
    }, [duration, onClose]);

    return (
        <div className="w-full max-w-xs mx-auto">

            <div id="toast-default" className="flex items-center p-4 bg-white rounded-lg shadow-sm" role="alert">
               
                {/* Content */}
                <div className="ms-3 text-sm font-normal text-black">{text}</div>

                {/* Close button */}
                <button type="button" className="ms-auto -mx-1.5 -my-1.5 text-gray-300 transition hover:text-gray-500 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-300 hover:scale-105 inline-flex items-center justify-center h-8 w-8" data-dismiss-target="#toast-default" aria-label="Close" onClick={onClose}>
                    <span className="sr-only">Close</span>
                    <CloseIcon />
                </button>
            </div>
            
            {/* indicator */}
            <div className="relative h-0.5 rounded-full">
                <div className="absolute top-0 left-0 h-full bg-violet-400 transition-all ease-linear" style={{ width: progress, transitionDuration: `${duration}ms` }} />
            </div>
    
        </div>
    )
}

使い方

rootなどにこんな形で使用します

layout.tsx
<ToastProvider>
    {children}
    <SessionProviderWrapper>
        <div className="absolute top-0 w-auto mx-auto">
            <NotificationListener /> //トーストのUIはこの位置に表示されます
        </div>
    </SessionProviderWrapper>
</ToastProvider>

Discussion