DEV Community

loading...

Advanced Blazor State Management Using Fluxor, part 7 - Client-to-Client comms with SignalR

mr_eking profile image Eric King Updated on ・6 min read

This is the seventh in a short series of blog posts where I will go beyond the introductory level and dig a bit deeper into using the Fluxor library in a Blazor Wasm project.

Client-to-Client Communication

Consider a scenario where you want the state of your application in one user's browser client to affect the state of your application in another user's browser client. Perhaps you have a component that updates in real-time based on the activities of other users. Or maybe you want to notify a user that the record they are edited was just saved by another user, and they should refresh their data before continuing.

So far in this series everything we've done has dealt with the state of individual components, or with components sharing state with other components in the same browser instance. Actions can affect the state of other components in the same browser instance, and Effects can interact with a server or external API, but still each browser instance is independent and unaware of any other browser instances running the same application.

In order to accommodate the client-to-client scenario, we'll have to incorporate some other tool that allows the server to push messages to all of the clients that have the application loaded.

Enter SignalR

As with most challenges, there are undoubtedly many ways to accomplish this. One of the simplest would be to incorporate ASP.NET Core SignalR into our Blazor Wasm client and server projects.

ASP.NET Core SignalR is a library for ASP.NET Core developers that makes it incredibly simple to add real-time web functionality to your applications. What is "real-time web" functionality? It's the ability to have your server-side code push content to the connected clients as it happens, in real-time.

There is a handy tutorial for incorporating SignalR into a Blazor WebAssembly application in the official documentation here. I encourage you to at least glance through it before continuing here, because the rest of this post is going to assume at least a basic familiarity with SignalR and configuration.

For this post, I want to take it a few steps further by incorporating it into the Fluxor Store setup we've been building.

Broadcasting the Counter state

I will be adding a button to the Counter screen that, when clicked, will broadcast the counter's current value to all of the other clients. I'm triggering this with an explicit button click just for demonstration convenience; it could just as easily be triggered automatically by subscribing to an Action as we did with the Weather Forecast updates in Part 4 of this series.

Alt Text

This "Broadcast" button will be disabled if the client loses connectivity to the server, and toast notifications will be used to indicate when broadcasted Counter values are received, similar to the previous few posts in the series.

The CounterHub

The SignalR component that manages the connections and messaging on the server is called a Hub. So our first step is creating a Hub in our BlazorWithFluxor.Server project, which I am calling CounterHub. I first create a folder at the root of the project called Hubs, and add file named CounterHub.cs that contains the following class:

using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;

namespace BlazorWithFluxor.Server.Hubs
{
    public class CounterHub : Hub
    {
        public async Task SendCount(int count)
        {
            await Clients.Others.SendAsync("ReceiveCount", count);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

This class derives from Microsoft.AspNetCore.SignalR.Hub and contains one method SendCount that clients will use to send a count value, and the method broadcasts the value to all of the other clients via Clients.Others.SendAsync("ReceiveCount", count). The first parameter of "ReceiveCount" represents the name of the message that clients will be listening for.

To activate the CounterHub, we need to call AddSignalR in the ConfigureServices method of Startup.cs, along with adding response compression like so:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSignalR();
    services.AddControllersWithViews();
    services.AddRazorPages();

    services.AddResponseCompression(opts =>
    {
        opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
            new[] { "application/octet-stream" });
    });
}
Enter fullscreen mode Exit fullscreen mode

The final piece for Startup.cs is to add an endpoint for the CounterHub in the UseEndpoints block:

app.UseEndpoints(endpoints =>
{
    endpoints.MapRazorPages();
    endpoints.MapControllers();
    endpoints.MapHub<CounterHub>("/counterhub");
    endpoints.MapFallbackToFile("index.html");
});
Enter fullscreen mode Exit fullscreen mode

And with that we have a working SignalR hub on the server.

The CounterHubStore

For the BlazorWithFluxor.Client project, we begin by referencing the Microsoft.AspNetCore.SignalR.Client NuGet package.

Install-Package Microsoft.AspNetCore.SignalR.Client
Enter fullscreen mode Exit fullscreen mode

Next we'll create a Fluxor store for the CounterHub, which I will place in a \Features\Hubs folder.

The CounterHubState will only need to keep track of whether or not it's connected to the server, which we'll use to enable/disable the Broadcast button. Since the client's HubConnection must reach out and communicate with the server's Hub, we will hold the HubConnection in the CounterHubEffects class. We will use an EffectMethod to configure the HubConnection and to communicate with the server Hub. With a handful of Actions to handle all of the interaction, our CounterHubStore looks like this:

using Fluxor;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using System;
using System.Threading.Tasks;

namespace BlazorWithFluxor.Client.Features.Hubs.CounterHub
{
    public record CounterHubState
    {
        public bool Connected { get; init; }
    };

    public class CounterHubFeature : Feature<CounterHubState>
    {
        public override string GetName() => "CounterHub";

        protected override CounterHubState GetInitialState()
        {
            return new CounterHubState 
            {
                Connected = false
            };
        }
    }

    public static class CounterHubReducers 
    {
        [ReducerMethod]
        public static CounterHubState OnSetConnected(CounterHubState state, CounterHubSetConnectedAction action) 
        {
            return state with
            {
                Connected = action.Connected
            };
        }
    }

    public class CounterHubEffects
    {
        private readonly HubConnection _hubConnection;

        public CounterHubEffects(NavigationManager navigationManager)
        {
            _hubConnection = new HubConnectionBuilder()
                .WithUrl(navigationManager.ToAbsoluteUri("/counterhub"))
                .WithAutomaticReconnect()
                .Build();
        }

        [EffectMethod]
        public async Task SendCount(CounterHubSendCountAction action, IDispatcher dispatcher)
        {
            try
            {
                if (_hubConnection.State == HubConnectionState.Connected)
                {
                    await _hubConnection.SendAsync("SendCount", action.Count);
                }
                else 
                {
                    dispatcher.Dispatch(new CounterHubSendCountFailedAction("Not connected to hub."));
                }
            }
            catch (Exception ex)
            {
                dispatcher.Dispatch(new CounterHubSendCountFailedAction(ex.Message));
            }
        }

        [EffectMethod(typeof(CounterHubStartAction))]
        public async Task Start(IDispatcher dispatcher) 
        {
            await _hubConnection.StartAsync();

            _hubConnection.Reconnecting += (ex) => 
            {
                dispatcher.Dispatch(new CounterHubSetConnectedAction(false));
                return Task.CompletedTask;
            };

            _hubConnection.Reconnected += (connectionId) =>
            {
                dispatcher.Dispatch(new CounterHubSetConnectedAction(true));
                return Task.CompletedTask;
            };

            _hubConnection.On<int>("ReceiveCount", (count) => dispatcher.Dispatch(new CounterHubReceiveCountAction(count)));

            dispatcher.Dispatch(new CounterHubSetConnectedAction(true));
        }
    }

    public record CounterHubSetConnectedAction(bool Connected);
    public record CounterHubStartAction();
    public record CounterHubReceiveCountAction(int Count);
    public record CounterHubSendCountAction(int Count);
    public record CounterHubSendCountFailedAction(string Message);

}
Enter fullscreen mode Exit fullscreen mode

Note that the server Hub is notified via an EffectMethod that handles the CounterHubSendCountAction Action, and the ReceiveCount server notification dispatches a CounterHubReceiveCountAction Action on the client. Fully incorporated into the flux pattern.

I dispatch the CounterHubStartAction in the MainLayout.razor file, to connect to the server once the client is ready:

@code { 
    protected override void OnInitialized()
    {
        base.OnInitialized();
        Dispatcher.Dispatch(new CounterHubStartAction());
    }
}
Enter fullscreen mode Exit fullscreen mode

I subscribe to a few new Actions in the Toaster.razor component:

protected override void OnInitialized()
{
    ...

    SubscribeToAction<CounterHubReceiveCountAction>(ShowCountReceivedToast);
    SubscribeToAction<CounterHubSendCountFailedAction>(ShowCountReceivedFailedToast);
    SubscribeToAction<CounterHubSetConnectedAction>(ShowHubConnectedToast);

    base.OnInitialized();
}
Enter fullscreen mode Exit fullscreen mode

and

private void ShowCountReceivedToast(CounterHubReceiveCountAction action)
{
    toastService.ShowInfo($"Count received: {action.Count}");
}

private void ShowCountReceivedFailedToast(CounterHubSendCountFailedAction action)
{
    toastService.ShowError($"Count could not be broadcast: {action.Message}");
}

private void ShowHubConnectedToast(CounterHubSetConnectedAction action)
{
    if (action.Connected)
    {
        toastService.ShowSuccess($"CounterHub connected!");
    }
    else 
    {
        toastService.ShowError($"CounterHub disconnected!");
    }
}
Enter fullscreen mode Exit fullscreen mode

And finally I add the Broadcast button in the Counter.razor component:

<button class="btn btn-outline-warning" 
        @onclick="SendCount" 
        disabled="@(!CounterHubState.Value.Connected)">
    Broadcast
</button>
Enter fullscreen mode Exit fullscreen mode

and dispatch the Current Count when the button is clicked:

private void SendCount()
{
    Dispatcher.Dispatch(new CounterHubSendCountAction(CounterState.Value.CurrentCount));
}
Enter fullscreen mode Exit fullscreen mode

With that complete, we can run our client in multiple browser windows to see the cross-client magic happen. For this example I used Firefox as one client and Chrome as the other, both connected to the same instance of the Server application.

Alt Text

If the persistent connection to the server hub is lost (in this example, I stopped the server host application), the CounterHubStore will react appropriately, disabling the Broadcast button and notifying the user.

Alt Text

Using the default WithAutomaticReconnect configuration, the clients will attempt to reconnect, and will re-enable the Broadcast button if successful.

Alt Text

This is what it looks like with multiple clients running at once:

Alt Text

One additional thing to note about SignalR is that you don't have to host the server Hub yourself if you don't want to. You could easily create a client-only Blazor WebAssembly serverless application and use Microsoft's Azure-based SignalR service to host the Hub in a massively scalable environment.

Please leave a comment if you have a question or suggestion. I'd love to hear from you if you found this useful.

In the meantime, happy coding!

Discussion (0)

Forem Open with the Forem app