Skip to main content

Local Storage Models in Blazor

Blazor is a new web framework by Microsoft which ships with .NET Core. In this post I explain how to use Local Storage in Blazor using the Blazored.LocalStorage package and how to improve on this by creating a custom service allowing you to use models (classes with properties) instead of string keys.

Blazored.LocalStorage

First I explain how to use local storage in Blazor. I assume you already have a blazor project setup.

Install the Blazored.LocalStorage package with using NuGet. This can be done using the command in the Visual Studio package manager:
Install-Package Blazored.LocalStorage

Then in the 'Startup.cs' file add the following line to 'ConfigureServices' to make the LocalStorage service available through dependency injection:
services.AddBlazoredLocalStorage();

Now you are ready to use local storage on your pages. To import the namespace on all pages you can add the following line to '_Imports.razor':
@using Blazored.LocalStorage

Now within your page you can obtain an instance of ILocalStorageService by adding the following line:
@inject ILocalStorageService localStorage

Then in your code you can use the following methods:
// Retrieve value from local storage with key "username"
string username = await localStorage.GetItemAsync<string>("username");

// Set value "James231" in local storage with key "username"
await localStorage.SetItemAsync<string>("username", "James231");

// Clear value in local storage with key "username"
await localStorage.RemoveItemAsync("username");

// Remove all data from local storage
await localStorage.ClearAsync();

The above code works for Client Side Blazor, but for Server Side Blazor there is one small difference: you can only use these methods within 'OnAfterRender' as it requires js interop.

While this might be all you need, it can be hard to keep track of all the keys you are using an the types they correspond to. In the remainder of this post I focus on improving this.

Using Models

Hopefully you are already familiar with the concept of a model. If not, it is just a class to put properties inside. They are often used in binding data in views (ViewModels), APIs (request/response models) or databases (e.g. entities in Entity Framework). Here we want a model containing all the data we'll have in local storage.

On each property in the model we need to identify which key it corresponds to in local storage. We'll do this using a custom attribute. Create a new C# file called 'LocalStorageAttribute' and add the following code (where you may wish to change the namespace):
using System;

namespace MyBlazorApp
{
    /// <summary>
    /// Specifies which key a property should be given in Local Storage.
    /// </summary>
    public class LocalStorageAttribute : Attribute
    {
        public LocalStorageAttribute(string keyName)
        {
            KeyName = keyName;
        }

        public string KeyName { get; set; }
    }
}

In my app I have one model for account data relating to the user, and other models for other things. This is what my user data model looks like:
namespace MyBlazorApp
{
    public class UserData : DataModelBase
    {
        [LocalStorage("username")]
        public string Username { get; set; }

        [LocalStorage("email")]
        public string Email { get; set; }

        [LocalStorage("email_verif")]
        public bool? EmailVerified { get; set; }

        [LocalStorage("token")]
        public string AuthToken { get; set; }

        [LocalStorage("forgotpass_token")]
        public string ForgotPassAuthToken { get; set; }
    }
}

There are three important things to notice here:
1. Every property has a [LocalStorage("keyName")] attribute to define the key it will have in local storage.
2. Every type is nullable. For instance I used 'bool?' instead of just 'bool'. This is because we'll give each property a null value if it is not present in local storage.
3. I am using a 'DataModelBase' base class for all my local storage data models. You don't have to do this, but it might be useful further down the line. I'm just using an empty base class:
namespace MyBlazorApp
{
    public class DataModelBase
    {
    }
}

Finally we need a way of loading and saving data into the model. I chose to create a service for this which can be accessed through dependency injection:
using System;
using System.Reflection;
using System.Threading.Tasks;
using Blazored.LocalStorage;

namespace MyBlazorApp
{
    /// <summary>
    /// Service to load and save Local Storage data given a LocalStorage data model.
    /// </summary>
    public class LocalDataService
    {
        private ILocalStorageService _storage;

        public LocalDataService(ILocalStorageService storageService)
        {
            _storage = storageService;
        }

        public async Task<T> LoadData<T>()
            where T : DataModelBase, new()
        {
            Type typeParam = typeof(T);
            MethodInfo getMethodInfo = typeof(ILocalStorageService).GetMethod("GetItemAsync");

            // Using reflection, iterate through all properties in the class and retrieve their values from storage
            T dataModel = new T();
            PropertyInfo[] properties = typeof(T).GetProperties();
            foreach (PropertyInfo property in properties)
            {
                // Use [LocalStorage("key")] attributes on the properties to get their storage keys
                LocalStorageAttribute lsatt = property.GetCustomAttribute<LocalStorageAttribute>();
                if (lsatt != null)
                {
                    string key = $"{typeParam.Name}.{lsatt.KeyName}";
                    Type pType = property.PropertyType;
                    MethodInfo genericGetCall = getMethodInfo.MakeGenericMethod(pType);
                    Task<object> task = InvokeMethodAsync(genericGetCall, _storage, new[] { key });
                    object pValue = await task;
                    property.SetValue(dataModel, pValue);
                }
            }

            return dataModel;
        }

        public async Task SaveData<T>(T dataModel)
            where T : DataModelBase, new()
        {
            Type typeParam = typeof(T);
            MethodInfo setMethodInfo = typeof(ILocalStorageService).GetMethod("SetItemAsync");
            PropertyInfo[] properties = typeof(T).GetProperties();
            foreach (PropertyInfo property in properties)
            {
                LocalStorageAttribute lsatt = property.GetCustomAttribute<LocalStorageAttribute>();
                if (lsatt != null)
                {
                    string key = $"{typeParam.Name}.{lsatt.KeyName}";
                    object value = property.GetValue(dataModel);
                    if (value != null)
                    {
                        Type pType = property.PropertyType;
                        MethodInfo genericSetCall = setMethodInfo.MakeGenericMethod(pType);
                        Task<object> task = InvokeMethodAsync(genericSetCall, _storage, new[] { key, value });
                        await task;
                    }
                    else
                    {
                        await _storage.RemoveItemAsync(key);
                    }
                }
            }
        }

        public async void ClearData()
        {
            await _storage.ClearAsync();
        }

        private async Task<object> InvokeMethodAsync(MethodInfo mi, object obj, params object[] parameters)
        {
            var task = (Task)mi.Invoke(obj, parameters);
            await task.ConfigureAwait(false);
            var resultProperty = task.GetType().GetProperty("Result");
            return resultProperty.GetValue(task);
        }
    }
}

I'm not going to go through this code in detail, instead you just need to be aware that there are three important methods 'LoadData', 'SaveData' and 'ClearData'. To use them we first need to add the service to our service collection so we can get it through dependency injection. i.e. in 'Startup.cs' add the following to ConfigureServices:
services.AddSingleton<Services.LocalDataService>();

Then on each page we can use the following:
@inject LocalDataService dataService;
...

// Load the model from local storage
UserData userData = await dataService.LoadData<UserData>();

// Change some of the values in the model
userData.EmailVerified = true;

// Save model changes to local storage
await dataService.SaveData<UserData>(userData);

// Clear all the data from local storage (all models)
await dataService.ClearData();

Hopefully this seems much nicer to work with than our origional string keys. But there are still some improvements we can make.

Clearing Up

Personally I don't like having the code behind services in a main Blazor project, so I would recommend moving it into a .NET standard library.

Also to neaten up 'Startup.cs' you can define a static extensions class like this:
public static class ServiceCollectionExtensions
{
    public static void AddLocalStorage(this IServiceCollection services)
    {
        services.AddBlazoredLocalStorage();
        services.AddSingleton<Services.LocalDataService>();
    }
}

Then you only need one line for adding local storage in 'Startup.cs':
services.AddLocalStorage();

Also if you are worrying about forgetting to call 'SaveData' you can also turn your model into a disposable object and use a 'using' block. Since you'll want it to execute asynchronously you'll need to use 'await using' which requires C# 8. To do this make your model base class inherit the interface 'IAsyncDisposable' and store a reference to the LocalDataService (also remember to modify the LocalDataService.LoadData method to set this value):
public class DataModelBase : IAsyncDisposable
{
    // set this in LoadData method when the instance of the model is created
    public LocalDataService DataService { get; set; }

    public virtual ValueTask DisposeAsync()
    {
        throw new NotImplementedException();
    }
}

And override the dispose method in your model:
public class UserData : DataModelBase
{
    ...
        
    public async override ValueTask DisposeAsync()
    {
        await base.DataService.SaveData<UserData>(this);
    }
}

Then you can use the following in your pages and 'SaveData' is called for you automatically:
await using(UserData userData = await DataService.LoadData<UserData>())
{
    if (userData.AuthToken != null)
    {
        // do stuff
    }

    userData.EmailVerified = true;
}

Thanks for reading!

The code here is part of a project which will be published on GitHub soon. If you want to know more or just found it useful please consider subscribing to the blog. You can also follow me on GitHub.

Comments are welcome!

Comments

Popular posts from this blog

Terminals in Sublime Text 3

TL;DR - Need a quick answer on how to create terminals in Sublime Text 3? Scroll down and watch the video or read the instructions below it. A while ago I started a series on YouTube of 'Sublime Text Tips'. Sublime Text 3 is one the best code editors currently in existence (fact), but most people just install it an use it without realising how it can be customized and extended to meet your needs. My aim was to release a series of videos explaining some of these widely unknown capabilities. I got as far as the third video and then got distracted with other things 😅 But recently I noticed the 3rd video I made has been increasing in popularity. For at least 6 months it sat at less than 200 views, and over the course of the last 2 months it has shot up to 850 (at the time of writing). Perhaps it's coincidence, or perhaps YouTube's algorithms have changed. Either way, there seem to be people who want this information. The video explains how to set up termin...

Generating a MSI Installer for a WPF Application

This post serves as a tutorial for creating a MSI installer for your WPF Applications in Visual Studio. For this we use WiX (recommended by Microsoft). Prerequisites I assume you already have a Visual Studio solution containing a WPF Project. And I assume you have some experience with Visual Studio and XML. You do not need any prior knowledge of WiX. Step 1: Install Wix We are going to need WiX to generate our MSI. If you don't already have Wix installed, go ahead and download it from here . You'll be redirected to the relevant GitHub releases page where you can download the .exe file to install WiX.

Best Packages for Sublime Text 3 (Excluding Themes)

Sublime Text 3 is pretty good out-of-the-box but reaches a whole new level when you install some of the great packages on offer. Below I'll list my favourite packages for Sublime Text. These are all packages which will enhance your productivity across all languages, so no themes or language-specific packages will be listed here.