Una minoría, pero aún una gran cantidad de proyectos, requieren la integración de sockets web para proporcionar una reacción instantánea de una interfaz ante los cambios sin tener que volver a buscar datos.
Es algo esencial y no vamos a hablar de ello ni a hacer una comparación entre bibliotecas de terceros que proporcionan API para una mejor experiencia de desarrollo.
Mi objetivo es mostrar cómo integrar rápidamente @microsoft/signalr
con NextJs. Y cómo resolver los problemas que enfrentamos durante el desarrollo.
Espero que todos ya hayan instalado e implementado el proyecto NextJS localmente. En mi caso la versión es 13.2.4
. Agreguemos algunas bibliotecas más importantes: swr
(versión 2.1.5
) para recuperar datos y trabajar más con el caché local y @microsoft/signalr
(versión 7.0.5
) - API para sockets web.
npm install --save @microsoft/signalr swr
Comencemos creando una función fetcher
simple y un nuevo enlace llamado useChatData
para obtener datos iniciales de nuestra API REST. Devuelve una lista de los mensajes del chat, campos que detectan errores y estado de carga, y el método mutate
que permite cambiar los datos almacenados en caché.
// hooks/useChatData.ts import useSWR from 'swr'; type Message = { content: string; createdAt: Date; id: string; }; async function fetcher<TResponse>(url: string, config: RequestInit): Promise<TResponse> { const response = await fetch(url, config); if (!response.ok) { throw response; } return await response.json(); } export const useChatData = () => { const { data, error, isLoading, mutate } = useSWR<Message[]>('OUR_API_URL', fetcher); return { data: data || [], isLoading, isError: error, mutate, }; };
Para probar que funciona como se supone, actualicemos el componente de nuestra página. Importe nuestro gancho en la parte superior y extraiga datos de él como en el fragmento a continuación. Si funciona, verá los datos renderizados. Como ves, es bastante sencillo.
// pages/chat.ts import { useChatData } from 'hooks/useChatData'; const Chat: NextPage = () => { const { data } = useChatData(); return ( <div> {data.map(item => ( <div key={item.id}>{item.content}</div> ))} </div> ); };
El siguiente paso requiere conectar nuestra página futura a sockets web, capturar eventos NewMessage
y actualizar un caché con un mensaje nuevo. Propongo comenzar construyendo el servicio de socket en un archivo separado.
Según los ejemplos de los documentos de SignalR, tenemos que crear una instancia de conexión para escuchar más eventos. También agregué un objeto de conexiones para evitar duplicados y dos ayudantes para iniciar/detener las conexiones.
// api/socket.ts import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; let connections = {} as { [key: string]: { type: string; connection: HubConnection; started: boolean } }; function createConnection(messageType: string) { const connectionObj = connections[messageType]; if (!connectionObj) { console.log('SOCKET: Registering on server events ', messageType); const connection = new HubConnectionBuilder() .withUrl('API_URL', { logger: LogLevel.Information, withCredentials: false, }) .withAutomaticReconnect() .build(); connections[messageType] = { type: messageType, connection: connection, started: false, }; return connection; } else { return connections[messageType].connection; } } function startConnection(messageType: string) { const connectionObj = connections[messageType]; if (!connectionObj.started) { connectionObj.connection.start().catch(err => console.error('SOCKET: ', err.toString())); connectionObj.started = true; } } function stopConnection(messageType: string) { const connectionObj = connections[messageType]; if (connectionObj) { console.log('SOCKET: Stoping connection ', messageType); connectionObj.connection.stop(); connectionObj.started = false; } } function registerOnServerEvents( messageType: string, callback: (payload: Message) => void, ) { try { const connection = createConnection(messageType); connection.on('NewIncomingMessage', (payload: Message) => { callback(payload); }); connection.onclose(() => stopConnection(messageType)); startConnection(messageType); } catch (error) { console.error('SOCKET: ', error); } } export const socketService = { registerOnServerEvents, stopConnection, };
Ahora nuestra página podría verse como en el fragmento de código. Recuperamos y extraemos data
con la lista de mensajes y los representamos. Además, useEffect
anterior registra el evento NewMessage
, crea una conexión y escucha el backend.
Cuando se activa el evento, el método mutate
del gancho actualiza la lista existente con un nuevo objeto.
// pages/chat.ts import { useChatData } from 'hooks/useChatData'; import { socketService } from 'api/socket'; const Chat: NextPage = () => { const { data } = useChatData(); useEffect(() => { socketService.registerOnServerEvents( 'NewMessage', (payload: Message) => { mutate(() => [...data, payload], { revalidate: false }); } ); }, [data]); useEffect(() => { return () => { socketService.stopConnection('NewMessage'); }; }, []); return ( <div> {data.map(item => ( <div key={item.id}>{item.content}</div> ))} </div> ); };
Me parece bien, funciona y vemos como aparecen nuevos mensajes en el feed. Elegí el ejemplo básico con chat porque es claro y fácil de entender. Y, por supuesto, lo aplicas según tu propia lógica.
Al utilizar una de las versiones ( @microsoft/signalr
), nos enfrentamos a un problema de duplicaciones. Estaba conectado a useEffect
, la matriz de dependencia. Cada vez que se cambió la dependencia, connection.on(event, callback);
devolución de llamada en caché y la activó una y otra vez.
useEffect(() => { // data equals [] by default (registerOnServerEvents 1 run), // but after initial data fetching it changes (registerOnServerEvents 2 run) // each event changes data and triggers runnning of registerOnServerEvents socketService.registerOnServerEvents( 'NewMessage', // callback cached (payload: Message) => { // mutate called multiple times on each data change mutate(() => [...data, payload], { revalidate: false }); } ); }, [data]); // after getting 3 messages events, we had got 4 messages rendered lol
La solución más rápida y confiable que encontramos fue mantener una copia de los datos dentro de React ref
y usarla dentro de useEffect
para futuras actualizaciones.
// pages/chat.ts import { useChatData } from 'hooks/useChatData'; import { socketService } from 'api/socket'; const Chat: NextPage = () => { const { data } = useChatData(); const messagesRef = useRef<Message[]>([]); useEffect(() => { messagesRef.current = chatData; }, [chatData]); useEffect(() => { socketService.registerOnServerEvents( 'NewMessage', (payload: Message) => { const messagesCopy = messagesRef.current.slice(); mutate(() => [...messagesCopy, payload], { revalidate: false }); } ); }, [data]); useEffect(() => { return () => { socketService.stopConnection('NewMessage'); }; }, []); return ( <div> {data.map(item => ( <div key={item.id}>{item.content}</div> ))} </div> ); };
Actualmente utilizamos una nueva versión de @microsoft/signalr
que parece que ya tiene las correcciones necesarias. Pero de todos modos, si alguien encuentra útil esta solución y la utiliza, estaré feliz. Para concluir, quiero decir que mi experiencia con SignalR es bastante positiva, la instalación no requirió dependencias ni configuraciones específicas, funciona bien y cubre nuestras necesidades.