Sync React with SignalR Events
In the previous article, we set up a SignalR hub for real-time cursor tracking in .NET. Now, we’ll integrate SignalR into a React app and manage the real-time cursor data within the React lifecycle.
Microsoft already does a great job explaining how to use the SignalR JavaScript client here, so I won’t rehash MSDN. Instead, we’ll focus on integrating SignalR into React's lifecycle.
This article is part of my series “Realtime webapps with SignalR and React".
- Realtime Cursor Tracking with .NET and React using SignalR.
- Sync React with SignalR Events (You are here).
- Improve Signalr and React Performance User Experience.
The Plan: Connecting React to SignalR Hub
Before diving into the code, let’s go over some important considerations when implementing this. We want to keep our solution performant, maintainable, and reusable for future real-time features.
Our solution will include:
- A
RealtimeCursorContext
: This context will centralize all logic and state, avoiding prop drilling. We’ll wrap it around the root or page component to ensure the connection is only created once where it’s needed. - Broadcasting our cursor position: We’ll capture and broadcast the cursor’s position using a custom hook. Hooks are perfect for isolating logic, and by including this in our context, we ensure that every time we use real-time cursor features, our own cursor is broadcasted.
- State management: The events received from the SignalR hub will be consolidated into an array containing all up-to-date connected cursors. Our context will manage this array and the SignalR connection.
Centralized Connection Management
Let’s start by connecting to the SignalR Hub. Since the RealtimeCursorContext
will handle the connection, it makes the most sense to manage it inside a custom context provider.
I like to structure React Context into three parts (usually in the same file for readability):
- The context itself, which we won’t export to prevent misue.
- A custom Context Provider to centralize all logic.
- A custom hook for added clarity and custom logic when needed.
A custom context provider is just a React component that wraps its children with the context provider, which is perfect for managing the context state.
Let’s start with the context itself:
_10type RealtimeCursorContextType = {_10 connection: HubConnection | null;_10};_10_10const RealtimeCursorContext =_10 React.createContext<RealtimeCursorContextType | null>(null);
Next, we’ll create our custom useContext
hook, useRealtimeCursor
, which throws an error if used outside a RealtimeCursorContext
:
_10export function useRealtimeCursor() {_10 const context = React.useContext(RealtimeCursorContext);_10 if (!context) {_10 throw new Error(_10 "useRealtimeCursor must be used within a RealtimeCursorProvider"_10 );_10 }_10 return context;_10}
Finally, we’ll create the custom provider. We’ll use the useEffect
hook to connect when the provider mounts and disconnect when it unmounts:
_35type RealtimeCursorProviderProps = {_35 children: React.ReactNode;_35};_35_35export function RealtimeCursorProvider({_35 children,_35}: RealtimeCursorProviderProps) {_35 const [connection, setConnection] = React.useState<HubConnection | null>();_35_35 React.useEffect(() => {_35 const newConnection = new HubConnectionBuilder()_35 .withUrl("https://localhost:7081/cursor")_35 .withAutomaticReconnect()_35 .build();_35_35 async function start() {_35 try {_35 await newConnection.start();_35 setConnection(newConnection);_35 console.log("RealtimeCursor Connected.");_35 } catch (err) {_35 setTimeout(start, 5000);_35 }_35 }_35_35 start();_35 return () => newConnection.stop(); //Close the connection when the provider unmounts_35 }, []);_35_35 return (_35 <RealtimeCursorContext.Provider value={{ connection }}>_35 {children}_35 </RealtimeCursorContext.Provider>_35 );_35}
Next, we’ll wrap our App
component with the RealtimeCursorProvider
:
_10//main.ts_10ReactDOM.createRoot(document.getElementById("root")!).render(_10 <RealtimeCursorProvider>_10 <App />_10 </RealtimeCursorProvider>_10);
Now we can access the connection in any component like this:
_10import { useRealtimeCursor } from "./RealtimeCursorContext";_10_10function SomeComponent() {_10const { connection } = useRealtimeCursor();_10 // ...the rest of the component_10}
By using this context, we have a centralized way to manage our SignalR connection, avoiding prop drilling and separating connection logic from UI logic. It’s much better than mixing SignalR logic directly into the root component!
Implementing Cursor Tracking in React
Next, we’ll use a custom hook in RealtimeCursorProvider
to track the user’s cursor and broadcast its position. This keeps all our cursor logic in a single component, following the Single Responsibility Principle.
The custom hook, useUpdateCursorLocation
, is a simple wrapper around a useEffect
hook that registers a mousemove
event listener whenever the connection changes:
_17export function useUpdateCursorLocation(connection: HubConnection | null) {_17 React.useEffect(() => {_17 const updateCursorPosition = (ev: MouseEvent) => {_17 const x = ev.clientX;_17 const y = ev.clientY;_17 if (connection?.state !== "Connected") {_17 return;_17 }_17 connection?.send("UpdateCursorPosition", x, y);_17 };_17_17 window.addEventListener("mousemove", updateCursorPosition);_17 return () => {_17 window.removeEventListener("mousemove", updateCursorPosition);_17 };_17 }, [connection]);_17}
We can add this hook to our RealtimeCursorProvider
to ensure that we’re always tracking and broadcasting the cursor position when required.
Keeping track of the other cursors
For other cursors, the hub we created in the previous article doesn’t provide a list of up-to-date cursors. Instead, it emits events that we can use to construct that list ourselves.
We’ll co-locate this logic inside our context, so our context not only returns the connection but also an updated list of other cursors.
This time, instead of reaching for useState
again, we’ll use useReducer
. useReducer
is specifically meant for complex state logic with action-based updates. Instead of modifying a single useState
inside all the different SignalR event listeners, we can create a reduce function that allows us to map each event to a state transition.
First we’ll have to create our reducer. A reducer accepts two parameters: the current state and the action we want to apply to our state. The return value from our reducer will be the newly updated state.
_20function cursorReducer(state: OtherCursor[], action: ReducerActionType) {_20 switch (action.type) {_20 case "start":_20 return action.payload;_20 case "add":_20 return [...state, action.payload];_20 case "remove":_20 return state.filter(_20 (cursor) => cursor.connectionId !== action.connectionId_20 );_20 case "update":_20 return state.map((cursor) =>_20 cursor.connectionId === action.connectionId_20 ? { ...cursor, x: action.x, y: action.y }_20 : cursor_20 );_20 default:_20 return state;_20 }_20}
Now, we’ll wire this up to the SignalR events in a custom hook. I like to keep everything granular and follow the Single Responsibility Principle:
_27function useCursorData(connection: HubConnection | null) {_27 const [cursorData, dispatch] = React.useReducer(cursorReducer, []);_27_27 React.useEffect(() => {_27 // We don't need to check the connection state_27 // since registering event listeners doesn't require a connected connection_27 if (!connection) {_27 return;_27 }_27 connection.on(_27 "CursorPositionUpdated",_27 (connectionId: string, x: number, y: number) =>_27 dispatch({ type: "update", connectionId, x, y })_27 );_27 connection.on("CursorConnected", (Cursor: OtherCursor) =>_27 dispatch({ type: "add", payload: Cursor })_27 );_27 connection.on("ConnectionEstablished", (mice: OtherCursor[]) =>_27 dispatch({ type: "start", payload: mice })_27 );_27 connection.on("CursorDisconnected", (connectionId: string) => {_27 dispatch({ type: "remove", connectionId });_27 });_27 }, [connection]);_27_27 return cursorData;_27}
This setup provides a clear separation between the events we listen to and the state transitions. The result is code that’s easier to maintain. The last step is to hook it up to our context.
Displaying the other cursors
With state management in place, displaying the other cursors is straightforward.
We only need two components: one for the HTML and CSS to show a cursor, and another to wire up the state and display a cursor for each connected client.
A simple cursor might look like this:
_10export function OtherCursor({ color, name, x, y }: OtherCursorProps) {_10 return (_10 <div style={{ background: color, position: "absolute", left: x, top: y }}>_10 <div>{name}</div>_10 </div>_10 );_10}
And we can wire it up like this:
_15export function OtherCursors() {_15 const { otherCursors } = useRealtimeCursor();_15 return (_15 <>_15 {otherCursors.map((cursor) => (_15 <OtherCursor_15 key={cursor.connectionId}_15 // We can spread the cursor since the_15 // component props and object properties overlap_15 {...cursor}_15 />_15 ))}_15 </>_15 );_15}
Now you can use <OtherCursors>
wherever you want to display the real-time cursors of others on your site! But before you go off to create awesome new websites, check out the next section where we’ll tackle performance and user experience issues!
Conclusion
With SignalR integrated into your React app, you now have a solid foundation for any real-time functionality.
By centralizing connection and state management in a single context, we’ve made our code more maintainable. Any additional features are easily added since we separated the state update logic into clear action-based updates with our reducer.
As your app grows, this setup will allow you to handle real-time data sources efficiently.
In the next article, we’ll introduce mouse event debouncing. Handling updates from hundreds of users 60 times per second isn’t ideal for production, and we’ll also address the user experience impact of debouncing. See you in the next one!
What to read next:
I really hope you enjoyed this article. If you did, you might want to check out some of these articles I've written on similar topics.
- Read .NET Aspire & Next.js: The Dev Experience You Were Missing— 7 minutes read.NET Aspire & Next.js: The Dev Experience You Were Missing
- Read Improve Signalr and React Performance User Experience— 4 minutes readImprove Signalr and React Performance User Experience
- Read Three ways to structure .NET Minimal APIs— 9 minutes readThree ways to structure .NET Minimal APIs