Storing user settings in a Blazor WebAssembly application

  • Gérald Barré

In an application, you often need to store user settings such as the selected theme, any application configuration, or their username. These settings must be:

  • Accessible from anywhere in the application
  • Persisted, so you can read them when the user restarts the application
  • Shared across multiple instances (tabs/windows in the context of a Blazor application)

In a WebAssembly application, that runs inside a browser, you must use the storage provided by the browser. There are multiple locations where you can store data:

  • Local storage:
    • Contains only strings
    • Limited to about 5MB
    • Can be notified when a value is written (Storage event)
  • IndexedDB database:
    • Store key-value pairs with structured values
    • Storage is almost not limited in size
    • Asynchronous API, so it doesn't block the UI
    • Usage is not easy
  • Cookies:
    • Limited in size (about 4k)
    • Sent with every HTTP request that match the URL, so it may impact every request
  • File and Directory Entries API:
    • Non-standard
    • Write files to the filesystem
    • Requires user consent

The easiest solution is to use the local storage. The settings are often, not that large and you can serialize data to JSON before storing them so it is a string. It also supports notification using the storage event when someone writes into the storage, so you can reload the data as needed.

First, you need to add some JS function into wwwroot/index.html to wrap the local storage APIs:

<script>
    function BlazorSetLocalStorage(key, value) {
        localStorage.setItem(key, value);
    }

    function BlazorGetLocalStorage(key) {
        return localStorage.getItem(key);
    }

    function BlazorRegisterStorageEvent(component) {
        window.addEventListener("storage", async e => {
            await component.invokeMethodAsync("OnStorageUpdated", e.key);
        });
    }
</script>

Then, you can add a new service to save to the storage. I don't want to use a save button in the UI, so the UserSettings class implements INotifyPropertyChanged. This way, you can automatically save the settings when the user changes the value of one property. Also, the provider reloads the settings when the data in the local storage changed.

public sealed class UserSettingsProvider
{
    private const string KeyName = "state";

    private readonly IJSRuntime _jsRuntime;
    private bool _initialized;
    private UserSettings _settings;

    public event EventHandler Changed;

    public bool AutoSave { get; set; } = true;

    public UserSettingsProvider(IJSRuntime jsRuntime)
    {
        _jsRuntime = jsRuntime;
    }

    public async ValueTask<UserSettings> Get()
    {
        if (_settings != null)
            return _settings;

        // Register the Storage event handler. This handler calls OnStorageUpdated when the storage changed.
        // This way, you can reload the settings when another instance of the application (tab / window) save the settings
        if (!_initialized)
        {
            // Create a reference to the current object, so the JS function can call the public method "OnStorageUpdated"
            var reference = DotNetObjectReference.Create(this);
            await _jsRuntime.InvokeVoidAsync("BlazorRegisterStorageEvent", reference);
            _initialized = true;
        }

        // Read the JSON string that contains the data from the local storage
        UserSettings result;
        var str = await _jsRuntime.InvokeAsync<string>("BlazorGetLocalStorage", KeyName);
        if (str != null)
        {
            result = System.Text.Json.JsonSerializer.Deserialize<UserSettings>(str) ?? new UserSettings();
        }
        else
        {
            result = new UserSettings();
        }

        // Register the OnPropertyChanged event, so it automatically persists the settings as soon as a value is changed
        result.PropertyChanged += OnPropertyChanged;
        _settings = result;
        return result;
    }

    public async Task Save()
    {
        var json = System.Text.Json.JsonSerializer.Serialize(_settings);
        await _jsRuntime.InvokeVoidAsync("BlazorSetLocalStorage", KeyName, json);
    }

    // Automatically persist the settings when a property changed
    private async void OnPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (AutoSave)
        {
            await Save();
        }
    }

    // This method is called from BlazorRegisterStorageEvent when the storage changed
    [JSInvokable]
    public void OnStorageUpdated(string key)
    {
        if (key == KeyName)
        {
            // Reset the settings. The next call to Get will reload the data
            _settings = null;
            Changed?.Invoke(this, EventArgs.Empty);
        }
    }
}

// The class that stores the user settings
public class UserSettings : INotifyPropertyChanged
{
    private string username;

    public string Username
    {
        get => username; set
        {
            username = value;
            RaisePropertyChanged();
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    private void RaisePropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

You now need to edit the Program.cs file to register the service:

public static async Task Main(string[] args)
{
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    builder.RootComponents.Add<App>("app");

    builder.Services.AddTransient(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
    builder.Services.AddScoped<UserSettingsProvider>();

    await builder.Build().RunAsync();
}

You can now use the service in any component. However, you may not want to edit all of them with the logic to get the settings and refresh it when the Changed event is triggered. One easier way to do that is to encapsulate the logic into a component a take advantages of cascading parameters to propagate the value to its children. Also, using StateHasChanged, you can refresh the children when the settings changed. Let's create a new component Shared/UserSettingsComponent.razor:

@inject UserSettingsProvider UserSettingsProvider
@implements IDisposable

@if (state == null)
{
    <p>loading...</p>
}
else
{
    <CascadingValue Value="@state" IsFixed="false">@ChildContent</CascadingValue>
}

@code{
    private UserSettings state = null;

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    protected override async Task OnInitializedAsync()
    {
        UserSettingsProvider.Changed += UserSettingsChanged;
        await Refresh();
    }

    public void Dispose()
    {
        UserSettingsProvider.Changed -= UserSettingsChanged;
    }

    private async void UserSettingsChanged(object sender, EventArgs e)
    {
        await InvokeAsync(async () =>
        {
            await Refresh();
            StateHasChanged();
        });
    }

    private async Task Refresh()
    {
        state = await UserSettingsProvider.Get();
    }
}

#How to get user settings in your components?

Modify the file App.razor to wrap the Router component into the UserSettingsComponent

<UserSettingsComponent>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</UserSettingsComponent>

You can now use the cascading parameter in any component:

@page "/"

<div class="form-group">
    <label for="exampleInputEmail1">Username</label>
    <input type="text" @bind="@State.Username" class="form-control" />
</div>

@code {
    [CascadingParameter]
    public UserSettings State { get; set; }
}

After validating the input, you should see the value in the local storage:

If you open multiple tabs, you should also see that the settings are reloaded automatically across instances:

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee