DEV Community

Eric King
Eric King

Posted on • Updated on

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

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 (8)

Collapse
jcoble profile image
jcoble

Hi Eric,

I have a question on the WeatherStore example. Let's say I needed to wait for the LoadForecasts() to be completed and then do more async tasks afterword's that was dependent on the forecasts array. This would then need to be in the OnInitializedAsync override. How would I wait for the LoadForecasts to finish and then do the rest of the async tasks that I need to do? Right now, if I test it, once it hits the await Http.GetFromJson(...)... it then continues on with the rest of the code, even though the LoadForecasts effect has not yet completed. Below is the code I'm referring to in your series that I would like to put in the OnInitializedAsync override and put code after it once the LoadForecasts() has finished.

private WeatherForecast[] forecasts => WeatherState.Value.Forecasts;
private bool loading => WeatherState.Value.Loading;

protected override void OnInitialized()
{
    if (WeatherState.Value.Initialized == false)
    {
        LoadForecasts();
        Dispatcher.Dispatch(new WeatherSetInitializedAction());
    }
    base.OnInitialized();
}

private void LoadForecasts()
{
    Dispatcher.Dispatch(new WeatherLoadForecastsAction());
}
Enter fullscreen mode Exit fullscreen mode
Collapse
jcoble profile image
jcoble

I answered my own question I'm pretty sure. I need to SubscribeToAction and then load the rest of the data once that has been called. Below is some code that in my actual app where I'm waiting on UserSetUserResponseAction to be called then loading the rest of the component data. I need the CurrentUser to be able to finish the component data, so if it is not Initialized yet, then load the user data by dispatching the UserLoadUserResponseAction, else just use the UserStore.Value.CurrentUser.

I'd like to be able to just use UserStore.Value.CurrentUser instead of using another property that I'm setting, from the action.UserResponse, but the UserSetUserResponseAction callback is called before the reducer is which sets it, so UserStore.Value.CurrentUser is not yet set, when the rest of the code is run.

    protected override void OnInitialized()
    {
        IsLoading = true;
        isFirstRender = true;
        SubscribeToAction<UserSetUserResponseAction>(async action => await LoadComponentData(action.UserResponse));
        if (UserState.Value.Initialized == false)
        {
            loadUserDate();
            Dispatcher.Dispatch(new UserSetInitalizedAction());
        }
        else
        {
            LoadComponentData(UserState.Value.CurrentUser);
        }
        base.OnInitialized();
    }

    private void loadUserDate()
    {
        Dispatcher.Dispatch(new UserLoadUserResponseAction());
    }

    private async Task LoadComponentData(UserResponse userResponse)
    {
        CurrentUser = userResponse;
        await loadTrainingCourses();
        HubConnection = HubConnection.TryInitialize(_navigationManager);
        if (HubConnection.State == HubConnectionState.Disconnected)
        {
            await HubConnection.StartAsync();
        }

        IsLoading = false;
        StateHasChanged();
    }
Enter fullscreen mode Exit fullscreen mode
Collapse
mr_eking profile image
Eric King Author

In the WeatherStore, you'll notice that the LoadForecasts EffectMethod dispatches the WeatherLoadForecastsSuccessAction to indicate that the forecasts have been loaded. If I want to have some action happen after the forecasts have been loaded, I can SubscribeToAction<WeatherLoadForecastsSuccessAction> and put whatever I need to do in there.

It looks like that's what you're doing with the SubscribeToAction<UserSetUserResponseAction> so I think we're on the same page there.

Collapse
jcoble profile image
jcoble

Thanks so much for the detailed and thorough tutorial series. I definitely learned so much more than the tutorial videos I've watched. Great job!

Collapse
quinton profile image
quintonv

Thank you so much for this series of articles and taking the time to illustrate and explain everything along the way. This has been instrumental in helping me learn Blazor.

Collapse
vbfan profile image
Razmi Martinez

Please could you share the code ?
Thank you

Collapse
mr_eking profile image
Eric King Author
Collapse
vbfan profile image
Razmi Martinez

Thank you