🦁
Next.jsのアプリにWebsocketを使った通知機能をつけてみた
目的
Next.jsを使って自主開発をしているwebアプリケーションに通知機能をつけてみようと思い、その実装を備忘録として残しておこうと思います。大きく分けて以下の二つです。
- websocketを使用したサーバークライアント間の通信の実装
- 通知をうけとり表示するフロントエンド側の通信の実装
- トースト通知を行うフロントエンドコンポーネントの実装
通知機能はこんなことに使う予定です
- 投稿を全ユーザーに通知する
- バッチ処理の終了を通知する
使っているもの
実行環境
- Next.js v15.1.5
- Tailwind CSS v3.4.1
- socket.io v4.8.1
技術
- Websocket
サーバー側
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