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.
Install the Blazored.LocalStorage package with using NuGet. This can be done using the command in the Visual Studio package manager:
Then in the 'Startup.cs' file add the following line to 'ConfigureServices' to make the LocalStorage service available through dependency injection:
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':
Now within your page you can obtain an instance of ILocalStorageService by adding the following line:
Then in your code you can use the following methods:
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.
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):
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:
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:
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:
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:
Then on each page we can use the following:
Hopefully this seems much nicer to work with than our origional string keys. But there are still some improvements we can make.
Also to neaten up 'Startup.cs' you can define a static extensions class like this:
Then you only need one line for adding local storage in 'Startup.cs':
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):
And override the dispose method in your model:
Then you can use the following in your pages and 'SaveData' is called for you automatically:
Comments are welcome!
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
Post a Comment