visit
My goal is to show how to integrate quickly @microsoft/signalr
with NextJs. And how to solve the problems we faced during development.
I hope everyone has already installed and deployed the NextJS project locally. In my case, the version is 13.2.4
. Let’s add some more important libraries: swr
(version 2.1.5
) for data fetching and further work with the local cache and @microsoft/signalr
(version 7.0.5
) - API for web sockets.
npm install --save @microsoft/signalr swr
Let’s start with creating a simple fetcher
function and a new hook called useChatData
to get initial data from our REST API. It returns a list of the messages for the chat, fields that detect errors and loading state, and the method mutate
that allows to change cached data.
// 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,
};
};
// 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>
);
};
The next step requires connecting our future page to web sockets, catching NewMessage
events, and updating a cache with a new message. I propose to start with building the socket service in a separate file.
// 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,
};
So now, our page might look like in the code snippet. We fetch and extract data
with the list of messages and render them. Also, useEffect
above registers the NewMessage
event, creates a connection, and listens to the backend.
When the event triggers, the mutate
method from the hook updates the existing list with a new object.
// 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>
);
};
Using one of the versions (@microsoft/signalr
), we faced a problem with duplications. It was connected to useEffect
, the dependency array. Each time the dependency was changed, connection.on(event, callback);
cached callback and triggered it again and again.
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
The quickest and most reliable solution we found was keeping a copy of data inside the React ref
and using it inside useEffect
for future updates.
// 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>
);
};
Currently, we use a new version of @microsoft/signalr
which it seems already has necessary fixes. But anyway, if someone finds this solution useful and uses this workaround, I will be happy. To conclude, I want to say that my experience with SignalR is quite positive, installation didn’t require any specific dependencies or settings, and it works fine and covers our needs.