The woes of keeping state with Blazor's InteractiveAuto rendermode

DEVELOPMENT -️ March 30, 2024

The goal

While researching and playing with .NET 8's Blazor I have stumbled upon the new InteractiveAuto mode. Theoretically this could allow applications to render pages server side, until the client is ready to use the clientside WASM code, which in theory should be faster and provide a better interactive experience. Reading this, it reminded me of how far SSR and hydration has come in JavaScript land (NextJS, Astro and more), and made me wonder how well Blazor stacks up to those technologies. In this post I will however not be drawing a comparison between Blazor and these JavaScript frameworks, I will solely be focusing on how Blazor implemented this feature.

To demonstrate how the InteractiveAuto mode works, we will be creating a component that: - Fetches data on the server. - Runs clientsided in WASM when the WASM bundle is ready. - If needed fetches the data clientsided in WASM if it has not been fetched yet.

The implementation

Setup

Let's generate a new Blazor application with InteractiveMode on auto. This generates a Server and Client Project. Generating from this template will include some boilerplate code regarding a Weather component that we will be reusing. The idea is that both the client as the server project will be using this weather forecast table to render forecasts.

dotnet new blazor -o BlazorApp -int Auto

Shared

As we want the server and the client to be able to generate our forecasts, we will be doing three things.

  • Create a Shared project on which both the Client and Server projects reference.
  • Move the WeatherForecast DTO to the Shared project
namespace Shared;

public class WeatherForecast
{
    public DateOnly Date { get; set; }
    public int TemperatureC { get; set; }
    public string? Summary { get; set; }
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
  • Create a common interface to fetch our forecasts.
public interface IWeatherForecastService
{
    IAsyncEnumerable<WeatherForecast> GetAllAsync();
}

The server

With our newly created interface, we can create a server implementation to fetch our forecasts. You will recognise most of this logic, as it was contained in the Weather.razor page, which we will be changing later on.

using Shared;

namespace BlazorApp.Services;

internal class WeatherForecastService : IWeatherForecastService
{
    private readonly static DateOnly StartDate = DateOnly.FromDateTime(DateTime.Now);

    private readonly static string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private readonly List<WeatherForecast> _forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = StartDate.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    }).ToList();

    public async IAsyncEnumerable<WeatherForecast> GetAllAsync()
    {
        for (var i = 0; i < _forecasts.Count; i++)
        {
            await Task.Delay(400);
            yield return _forecasts[i];
        }
    }
    
}

You might have noticed that the interface uses IAsyncEnumerable, combined with the implementation that delays before yielding values in this IAsyncEnumerable. We are purposely using IAsyncEnumerable as it plays nicely with Blazor's StreamRendering. We introduce the Task.Delay to make it visually easier to spot what is going on with our data fetching.

Next we will be wiring up this service in the Server's Program.cs. We're basically handling static data here, so we can opt for registering the service as a singleton.

builder.Services.AddSingleton<IWeatherForecastService, WeatherForecastService>();

The client

As the client runs in the browser, we will need to fetch the data from the server. The most straightforward way to do that is executing HTTP calls.

First we will need to expose the Server's IWeatherForecastService through an endpoint. In the Server's Program.cs

app.MapGet("/api/weather", (IWeatherForecastService weatherService) => weatherService.GetAllAsync());

In the Client we will call this endpoint using the HttpClient

internal sealed class ClientWeatherForecastService(HttpClient httpClient) : IWeatherForecastService
{
    public IAsyncEnumerable<WeatherForecast> GetAllAsync() =>
        httpClient.GetFromJsonAsAsyncEnumerable<WeatherForecast>("/api/weather")!;
}

Subsequently we wire up our HttpClient and ClientWeatherForecastService in the client's Program.cs.

builder.Services.AddSingleton(new HttpClient
{
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
builder.Services.AddSingleton<IWeatherForecastService, ClientWeatherForecastService>();

Consuming our IWeatherForecastService's data

In the client project, we will be creating our Weather page.

@page "/weather"
@using Shared
@attribute [StreamRendering]
@rendermode InteractiveAuto
@inject IWeatherForecastService WeatherForecastService
@inject PersistentComponentState ApplicationState
@implements IDisposable

<PageTitle>Weather</PageTitle>

@if (_forecasts.Count == 0)
{
    <p>
        <em>Loading...</em>
    </p>
}
else
{
    <table class="table">
        <thead>
        <tr>
            <th>Date</th>
            <th>Temp. (C)</th>
            <th>Temp. (F)</th>
            <th>Summary</th>
        </tr>
        </thead>
        <tbody>
        @foreach (var forecast in _forecasts)
        {
            <tr>
                <td>@forecast.Date.ToShortDateString()</td>
                <td>@forecast.TemperatureC</td>
                <td>@forecast.TemperatureF</td>
                <td>@forecast.Summary</td>
            </tr>
        }
        </tbody>
    </table>
}

@code {
    private List<WeatherForecast> _forecasts = [];
    private PersistingComponentStateSubscription _subscription;

    protected override async Task OnInitializedAsync()
    {
        _subscription = ApplicationState.RegisterOnPersisting(Persist);
        var foundInState = ApplicationState.TryTakeFromJson<List<WeatherForecast>>("weather", out var forecasts);

        if (foundInState)
        {
            _forecasts = forecasts!;
            Console.WriteLine($"{DateTime.Now:O} Took forecast from storage"); 
        }
        else
        {
            Console.WriteLine($"{DateTime.Now:O} Took forecast from service");

            Console.WriteLine(WeatherForecastService is ClientWeatherForecastService
                ? $"{DateTime.Now:O} Client forecast service"
                : $"{DateTime.Now:O} Server forecast service");

            await foreach (var forecast in WeatherForecastService.GetAllAsync())
            {
                _forecasts.Add(forecast);
                StateHasChanged();
            }
        }
    }

    private Task Persist()
    {
        Console.WriteLine("Persisting state");
        ApplicationState.PersistAsJson("weather", _forecasts);
        return Task.CompletedTask;
    }

    public void Dispose() => _subscription.Dispose();
}

A few things to take a look at:

  • We are using the await foreach syntax to get values from our IAsyncEnumerable which is feeding our records one at a time leveraging [StreamRendering] which makes sure our DOM updates one record at a time.
  • According to the documentation it is possible to persist initial prerendered serverside state using PersistentComponentState. Here be dragons though, which we will discover below.
  • We added some lazy log statements to identify how and where the data is being fetched from.

Trying out our component

Theres two scenarios we are we want to look at: 1) How does the data get fetched when the WASM bundle was not preloaded, and we hotlink to the page? 2) How does the data get fetched when the WASM bundle has already been downloaded, and we execute an enhanced page navigation to our weather page?

2024-03-23T16:57:25.7357450+01:00 Took forecast from service
2024-03-23T16:57:25.7358220+01:00 Server forecast service
2024-03-23T16:57:26.9087260+01:00 Persisting state
2024-03-23T16:57:29.4020000+01:00 Took forecast from storage

Logically, as the WASM bundle is not ready yet, our content gets generated on the server, and rehydrated from the PersistentComponentState during rerenders.

Enhanced page navigation + WASM bundle ready

We boot up the application, visit the home page, wait for the WASM bundle to fully load, and navigate to the weather page.

2024-03-23T16:50:53.9740720+01:00 Took forecast from service
2024-03-23T16:50:53.9757560+01:00 Server forecast service
2024-03-23T16:50:55.1677860+01:00 Persisting state
2024-03-23T16:50:56.6540000+01:00 Took forecast from storage

If we navigate to the home page and back to the weather page, we see a different result:

2024-03-23T16:52:12.5185440+01:00 Took forecast from service
2024-03-23T16:52:12.5186120+01:00 Server forecast service
2024-03-23T16:52:13.6435220+01:00 Persisting state
2024-03-23T16:52:13.6470000+01:00 Took forecast from service
2024-03-23T16:52:13.6480000+01:00 Client forecast service

Visually this also results in a different UX:

  • we see some records flowing in
  • followed by our temporary loading message
  • followed by yet again records flowing in

I'm no UX expert, but it is pretty obvious to me that this looks like a very confusing and jarring experience.

I see a few alarming problems here:

  • The data gets fetched twice. Somehow we are using the server's IWeatherForecastService twice:

    • Directly in our component when prerendering on the server.
    • Indirectly on the WASM side when calling our API which in turn uses the server's IWeatherForecastService implementation.
  • The UI has all the data due to it streaming from the server, suddenly goes back to the initial state showing the loading state, followed by fetching the same data again. Switching to the client side, we're not using the persistent state, and giving the user a very confusing user experience.

This can't be how Blazor team intended for this work right? Well... no, not really. This GitHub issue is part of the problem as it explains us that:

  • Persistent component state only works during the initial render of the components for a given runtime
  • It (Persistent Component State) doesn't know about enhanced navigations and because we don't have a mechanism to deliver state updates to components that are already running

Well that's a bummer. Looks like I made some assumptions there. What about not using PersistentComponentState? In this case it does not matter as it does not function in the way I imagined it to work. I've built a 'dumb' weather component to test this out:

@page "/dumbweather"
@using Shared
@using System.Globalization
@attribute [StreamRendering]
@rendermode InteractiveAuto
@inject IWeatherForecastService WeatherForecastService

<PageTitle>Weather</PageTitle>

@if (_forecasts.Count == 0)
{
    <p>
        <em>Loading...</em>
    </p>
}
else
{
    <table class="table">
        <thead>
        <tr>
            <th>Date</th>
            <th>Temp. (C)</th>
            <th>Temp. (F)</th>
            <th>Summary</th>
        </tr>
        </thead>
        <tbody>
        @foreach (var forecast in _forecasts)
        {
            <tr>
                <td>@forecast.Date.ToString(CultureInfo.InvariantCulture)</td>
                <td>@forecast.TemperatureC</td>
                <td>@forecast.TemperatureF</td>
                <td>@forecast.Summary</td>
            </tr>
        }
        </tbody>
    </table>
}

@code {
    private readonly List<WeatherForecast> _forecasts = [];

    protected override async Task OnInitializedAsync()
    {
        {
            Console.WriteLine($"{DateTime.Now:O} Took forecast from service");

            Console.WriteLine(WeatherForecastService is ClientWeatherForecastService
                ? $"{DateTime.Now:O} Client forecast service"
                : $"{DateTime.Now:O} Server forecast service");

            await foreach (var forecast in WeatherForecastService.GetAllAsync())
            {
                _forecasts.Add(forecast);
                StateHasChanged();
            }
        }
    }

}

Expectedly, it gives us the same result. We get the same 'flashing' effect.

2024-03-30T01:28:56.7847000+01:00 Took forecast from service
2024-03-30T01:28:56.7857840+01:00 Server forecast service
2024-03-30T01:28:59.6380000+01:00 Took forecast from service
2024-03-30T01:28:59.6690000+01:00 Client forecast service

The solution

So Alex, what's the solution? I want to use this glorious mix of serverside and clientside rendering!

The way I see it there are two options:

  • Hope that the GitHub issue linked above gets picked up for .NET 9.0 and wait for .NET 9.0. I bet your superior won't like hearing you say that you need to wait until the .NET 9.0 release though.
  • Do not use InteractiveAuto for stateful components. Go full server or full clientsided WASM instead and dodge the complexity altogether. Just maybe this is a sign that Blazor's InteractiveAuto mode just isn't there yet; well atleast if your components have any state ☺.

All the code above can be found on my GitHub.

Initializing...