7 minutes read

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.

Three browsers with a cursor on each, connected to a SignalR hub through a React app.

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".

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:


_10
type RealtimeCursorContextType = {
_10
connection: HubConnection | null;
_10
};
_10
_10
const 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:


_10
export 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:


_35
type RealtimeCursorProviderProps = {
_35
children: React.ReactNode;
_35
};
_35
_35
export 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
_10
ReactDOM.createRoot(document.getElementById("root")!).render(
_10
<RealtimeCursorProvider>
_10
<App />
_10
</RealtimeCursorProvider>
_10
);

Now we can access the connection in any component like this:


_10
import { useRealtimeCursor } from "./RealtimeCursorContext";
_10
_10
function SomeComponent() {
_10
const { 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:


_17
export 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.


_20
function 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:


_27
function 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:


_10
export 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:


_15
export 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.