Persisting your users preferences using Blazor and Local Storage

January 14, 2021 · 11 minute read · Tags: blazor

In the last post we saw how you can use TailwindCSS with Blazor to toggle “dark mode” on and off in your web applications.

But it’s a pain for your users to have to do this every time. It would be much nicer if this setting was persisted in some way. That way, they’d get the same “mode” each and every time they visit your site.

Unleash Blazor's Potential

Blazor promises to make it much easier (and faster) to build modern, responsive web applications, using the tools you already know and understand.

Subscribe to my Practical ASP.NET Blazor newsletter and get instant access to the vault.

In there you'll find step-by-step tutorials, source code and videos to help you get up and running using Blazor's component model.

I respect your email privacy. Unsubscribe with one click.

    Local Storage to the rescue

    The simplest way to persist preferences like these is in the browser itself, using local storage.

    Now we could use Javascript Interop from our Blazor app to interact with local storage but it’s much more convenient to use the handy Blazored.LocalStorage NuGet package from Chris Sainty.

    Follow the instructions here to add and configure Blazored.LocalStorage for your app.

    Before we can think about using Local Storage to persist our preferences, we need to do a little refactoring on the implementation we ended up with last time.

    Here’s ThemeToggle.Razor as it currently stands…

    <button @onclick="Toggle">Toggle</button>
    
    @code {
    
        private bool _darkMode = false;
    
        [Parameter]
        public EventCallback<bool> OnDarkModeToggled { get; set; }
    
        private void Toggle()
        {
            _darkMode = !_darkMode;
            OnDarkModeToggled.InvokeAsync(_darkMode);
        }
    }
    

    Note how we’re storing a value for _darkMode directly in the component, but we’re also storing it somewhere else too, in MainLayout.razor.

    
    <!-- markup here -->
    
    @code
    {
        private bool _darkMode = false;
        
        private void HandleDarkModeToggled(bool isDarkMode)
        {
            _darkMode = isDarkMode;        
        }
    }
    

    So looking at this again, we’re effectively caching the boolean value for whether DarkMode is in use or not in two places.

    If we were to then store this in LocalStorage too we’d be holding that value in three places.

    This feels like overkill, and will likely require us to jump through unnecessary hoops to keep them all in sync.

    In this case I’d lean towards consolidating that state into one place, so we can implement our business rules and logic (such as defaulting to the OS preference or value stored in Local Storage) without fear of repeating ourselves throughout the application.

    Refactor shared state into one place (one source of truth)

    Let’s start by creating a simple ProfileService and a Preferences record to store the DarkMode preference itself…

    ProfileService.cs

    public record Preferences
    {
        public bool DarkMode { get; init; } 
    }
    
    public class ProfileService {
    
        public async Task ToggleDarkMode(){
    
        }
    
        public async Task<Preferences> GetPreferences(){
    
        }
    
    }
    

    The idea is we’ll call ToggleDarkMode to turn Dark Mode on or off, and we can retrieve the current preference using GetPreferences.

    Let’s start by implementing ToggleDarkMode.

    I’ll make use of C#’s shiny new record feature here (because, well… you know… it’s new, and shiny!) and with expressions to flip Dark Mode on and off…

    ProfileService.cs

    public async Task ToggleDarkMode()
    {
        var preferences = await GetPreferences();
        var newPreferences = preferences 
            with { DarkMode = !preferences.DarkMode }; 
    }
    

    This gives us a new instance of the Preferences record with DarkMode set to the opposite of what it was before.

    But what are we to do with this new preferences instance? This is where we can lean on LocalStorage to persist our new preferences.

    If we inject ILocalStorageService (from Blazored.LocalStorage) into our ProfileService we can use it to persist these preferences to Local Storage.

    ProfileService.cs

    public class ProfileService {
    
        private readonly ILocalStorageService _localStorageService;
    
        public ProfileService(ILocalStorageService localStorageService)
        {
            _localStorageService = localStorageService;
        }
    
        public async Task ToggleDarkMode(){
            var preferences = await GetPreferences();
            var newPreferences = preferences 
                with { DarkMode = !preferences.DarkMode };
    
            await _localStorageService.SetItemAsync("preferences", newPreferences);
        }
    
        public async Task<Preferences> GetPreferences(){
            return await _localStorageService.GetItemAsync<Preferences>("preferences") 
                ?? new Preferences();
        }
    }
    

    Now Dark Mode will be toggled on or off, then persisted to Local Storage.

    Naturally, we want to be able to retrieve these preferences too so I’ve also implemented GetPreferences to look the preferences up from Local Storage, defaulting to a new instance of Preferences if nothing has yet been saved.

    Register ProfileService for use via dependency injection

    Now is also a good time to make sure that ProfileService is registered as a service so we can use it wherever we see fit. How you do this depends on whether you’re using Blazor WASM or Blazor Server.

    In this case I’m using Blazor WASM so I need to head over to Program.cs and register the service there:

    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<App>("#app");
    
        builder.Services.AddScoped(
            sp => new HttpClient {BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)});
    
        builder.Services.AddScoped<ProfileService>();
        builder.Services.AddBlazoredLocalStorage();
        
        await builder.Build().RunAsync();
    }
    

    Access shared state directly from Blazor components

    So far so good, but we’re not actually using this ProfileService anywhere. To do that we’ll need to make a small tweak to…

    ThemeToggle.Razor

    @inject ProfileService ProfileService
    
    <button @onclick="Toggle">Toggle</button>
    
    @code {
    
        private void Toggle()
        {
            ProfileService?.ToggleDarkMode();
        }
    }
    

    We’ve removed the local boolean for _darkMode and replaced it with a direct call to Toggle Dark Mode by invoking ProfileService.ToggleDarkMode().

    Note we’ve also removed the OnDarkModeToggled EventCallback parameter, because it’s now redundant.

    We don’t want to wait until the user has tried to toggle Dark mode to read these preferences. They may have already switched to dark mode during a previous visit, so we want to be able to read their preferences and apply them whether or not they click the button to toggle dark mode on/off.

    We need to fetch this state when the app is rendered for the first time, not just when the user clicks that button. For this reason we don’t need that event callback anymore and will make sure to retrieve the relevant state elsewhere.

    Which brings us to the next challenge; how do we retrieve the preferences from ProfileService in order to actually switch themes?

    For that we need to head over to MainLayout.razor.

    As a reminder, here’s how it currently looks:

    @inherits LayoutComponentBase
    
    <div class="flex flex-col h-screen @(_darkMode ? "dark" : "")">
    
        <div class="bg-gray-600 p-4 flex justify-between">
            <h1 class="text-white">Blazor WASM Demo App</h1>
            <ThemeToggle OnDarkModeToggled="@HandleDarkModeToggled"/>
        </div>
    
        <div class="flex-grow flex dark:bg-gray-700">
            <div class="bg-gray-100 px-8 w-48 py-4 border-r shadow-md dark:bg-gray-500">
                <NavMenu/>
            </div>
    
            <div class="flex-grow w-auto p-8 mx-auto max-w-5xl">
                @Body
            </div>
        </div>
    
    </div>
    
    @code
    {
        private bool _darkMode = false;
        
        private void HandleDarkModeToggled(bool isDarkMode)
        {
            _darkMode = isDarkMode;        
        }
    }
    

    The task here is to bring in our ProfileService and use that to retrieve preferences, then use those preferences to drive whether dark mode is enabled or not.

    @inherits LayoutComponentBase
    @inject ProfileService ProfileService
    
    <div class="flex flex-col h-screen @(_preferences.DarkMode ? "dark" : "")">
    
        <div class="bg-gray-600 p-4 flex justify-between">
            <h1 class="text-white">Blazor WASM Demo App</h1>
            <ThemeToggle/>
        </div>
    
        <div class="flex-grow flex dark:bg-gray-700">
            <div class="bg-gray-100 px-8 w-48 py-4 border-r shadow-md dark:bg-gray-500">
                <NavMenu/>
            </div>
    
            <div class="flex-grow w-auto p-8 mx-auto max-w-5xl">
                @Body
            </div>
        </div>
    
    </div>
    
    @code
    {
        private Preferences _preferences = new();
    
        protected override async Task OnInitializedAsync()
        {
            _preferences = await ProfileService.GetPreferences();
        }    
    }
    

    We’ve made a couple of changes here:

    <ThemeToggle /> no longer takes an OnDarkModeToggled EventCallback Parameter so we’ve removed the code which assigned that.

    We retrieve the user’s preferences from ProfileService during OnInitializedAsync and store the preferences in a field which our markup then reads to determine whether to switch to Dark Mode or not.

    Tell Blazor to re-render

    We’re almost there, but run this now, click the button and…

    … nothing happens!

    Well actually, something does happen, but the theme doesn’t change.

    Crack open the developer tools, hit the Application tab (in Chrome/Edge) and you’ll see the local storage being set as you click the button….

    But Blazor isn’t re-rendering, and for good reason… we haven’t told it to.

    When we click to the toggle button it calls ProfileService which in turn updates Local Storage, but we never tell the UI to re-render and there’s nothing in place to make this happen automatically.

    This is where we can use good old C# events to make sure we can react and trigger a render when preferences have changed.

    Here’s an updated version of ProfileService which invokes an event every time preferences are changed.

     public class ProfileService
    {
        private readonly ILocalStorageService _localStorageService;
    
        public event Action<Preferences> OnChange;
    
        public ProfileService(ILocalStorageService localStorageService)
        {
            _localStorageService = localStorageService;
        }
    
        public async Task ToggleDarkMode()
        {
            var preferences = await GetPreferences();
            var newPreferences = preferences with {DarkMode = !preferences.DarkMode};
            await _localStorageService.SetItemAsync("preferences", newPreferences);
    
            OnChange?.Invoke(newPreferences);
        }
    
        public async Task<Preferences> GetPreferences()
        {       
            return await _localStorageService.GetItemAsync<Preferences>("preferences") 
                ?? new Preferences();   
        }
    }
    

    With this we get a handy event which will be raised each and every time Dark mode is toggled (on or off).

    Now we just need to hook into this event in MainLayout.razor to trigger a re-render of our component.

    @inherits LayoutComponentBase
    @inject ProfileService ProfileService
    @implements IDisposable
    
    //markup omitted for brevity
    
    @code
    {
        private Preferences _preferences = new();
    
        protected override async Task OnInitializedAsync()
        {
            ProfileService.OnChange += ProfileServiceOnOnChange;
            _preferences = await ProfileService.GetPreferences();
        }
    
        private void ProfileServiceOnOnChange(Preferences newPreferences)
        {
            _preferences = newPreferences;
            StateHasChanged();
        }
    
        public void Dispose()
        {
            ProfileService.OnChange -= ProfileServiceOnOnChange;
        }
    
    }
    

    We register ProfileServiceOnChange as a handler for ProfileService.OnChange. The result is this method will be invoked every time OnChange is invoked by code in ProfileService.

    Because the event includes the new preferences as an argument it’s easy to grab those and update the preferences in MainLayout accordingly.

    We have to call StateHasChanged so Blazor knows to re-render at this point.

    Note we’ve also implemented IDisposable and made to sure to unregister the event handler as well. This is good practice to make sure we’re cleaning up after ourselves!

    With that we can click the button to toggle dark mode, knowing that we’ll get the new theme we want and the preferences will be stored in local storage for next time.

    Respect their OS preferences

    One last thing to consider, your visitors may have set dark mode as their preference for apps at OS level.

    Here’s the relevant Windows setting.

    It’s pretty straightforward to read this setting using javascript…

    window.matchMedia('(prefers-color-scheme: dark)').matches;
    

    If this returns true then they’ve selected dark mode as their preference.

    We can access this from Blazor via JS interop…

    First we need to write the javascript, in this case we can just add it to index.html (for Blazor WASM) or _host.cshtml (for Blazor Server).

    index.html

    <body>
        <div id="app">Loading...</div>
    
        <div id="blazor-error-ui">
            An unhandled error has occurred.
            <a href="" class="reload">Reload</a>
            <a class="dismiss">🗙</a>
        </div>
        <script src="_framework/blazor.webassembly.js"></script>
    
    <script>
        window.prefersDarkMode = function(){
            return window.matchMedia('(prefers-color-scheme: dark)').matches;
        }
    </script>
    
    </body>
    

    Now ProfileService can read this when deciding which theme to initially prefer…

    public class ProfileService
    {
        private readonly ILocalStorageService _localStorageService;
        private readonly IJSRuntime _jsRuntime;
    
        public event Action<Preferences> OnChange;
    
        public ProfileService(
            ILocalStorageService localStorageService, 
            IJSRuntime jsRuntime)
        {
            _jsRuntime = jsRuntime;
            _localStorageService = localStorageService;
        }
    
        public async Task ToggleDarkMode()
        {
            var preferences = await GetPreferences();
            var newPreferences = preferences with {DarkMode = !preferences.DarkMode};
            await _localStorageService.SetItemAsync("preferences", newPreferences);
    
            OnChange?.Invoke(newPreferences);
        }
    
        public async Task<Preferences> GetPreferences()
        {
            // if they've already specified their preferences explicitly, use them
            if (await _localStorageService.ContainKeyAsync("preferences"))
                return await _localStorageService.GetItemAsync<Preferences>("preferences");
    
            // else default to OS settings...
            var prefersDarkMode = await _jsRuntime.InvokeAsync<bool>("prefersDarkMode");
    
            return new Preferences
            {
                DarkMode = prefersDarkMode
            };
        }
    }
    

    In summary

    When you need to store a user’s preferences consider using Local Storage.

    To avoid keeping state in multiple places you can use a service to handle getting and setting state, this is also a good place to put any business logic you need to be consistent across multiple use cases. There are other solutions to this challenge of storing state in Blazor which might also be worth a look…

    If you need to re-render based on state changes, consider using an event and event callback to react to state changes and re-render your Blazor components accordingly.

    Join the Practical ASP.NET Newsletter

    Ship better Blazor apps, faster. One practical tip every Tuesday.

    I respect your email privacy. Unsubscribe with one click.

      Next up

      .NET 9 improves JavaScript module importing for Blazor
      .NET 9 ensures your users always get the latest version of your JS modules
      How to use .NET 9 to ensure users always get the latest version of your stylesheets
      .NET 9 changes how static files are served, and it solves a long-standing problem
      Preventing double clicks in Blazor components
      Guard against duplicate events in your Blazor app