NOTE: This episode updates two BlazorTrain videos from 2020, each linking to code in zip files. Here are the original links:
Episode 15: Application State
YouTube: https://youtu.be/BB4lK2kfKf0
Code: https://blazorroadshow.azurewebsites.net/blazortrainfiles/ApplicationState.zip
Episode 17: Persisiting Application State
YouTube: https://youtu.be/ib_6mYbkL2s
Code: https://blazorroadshow.azurewebsites.net/blazortrainfiles/PersistingAppState.zip
Application State (or app state) is all of the variables (objects, lists, etc.) that you keep alive in your application while in use. So far we have been keeping all of our variables in code blocks inside of pages. The problem with this approach is that those variables get reinitialized every time the page is navigated to or refreshed.
The key to app state is to move it to a component that lives outside of the pages so that
- variables persist between navigations and page refreshes
- variables can be shared by all pages and components
- We want UI to automatically update via binding whenever any of the properties update.
- We want to get control when the state gets mutated.
- We want to know who (which component) changed the state.
The approach we will take is to use a Cascading Value. This is essentially an object reference that can be accessed by any component in the render tree below where it is defined. If we want all pages and components to have access to it, we can wrap it around the Router in App.razor, but I'm getting ahead of myself.
Observe the default behavior
Run the app (F5)
Go to the Counter page and increment the counter, now navigate to the Home page (Index) and back to Counter. Notice that the counter has been reset to zero!
This is because the counter value itself (currentCount) is defined within the page.
@page "/counter"<PageTitle>Counter</PageTitle><h1>Counter</h1><p role="status">Current count: @currentCount</p><button class="btn btn-primary"@onclick="IncrementCount">Click me</button>
@code {privateint currentCount =0;
private void IncrementCount(){currentCount++;}}Remember from the section on the component lifecycle, whenever you navigate to a page, it is reinitialized. All of the module-level variables (such as currentCount) get reset to default values.
Let's fix that on our way to provide state to the entire app.
<CascadingValueValue="this">@ChildContent</CascadingValue>@code{[Parameter]public RenderFragment ChildContent {get;set;}
private string Caller = "";publicvoidSetProperty(ComponentBasecaller,stringPropertyName,objectPropertyValue){try{varprop=this.GetType().GetProperty(PropertyName);if(prop!=null){this.Caller=caller.GetType().Name;prop.SetValue(this,PropertyValue);StateHasChanged();}}catch(Exceptionex){}}privatestringmessage="";publicstringMessage{
get =>message;privateset{message=value;if(Caller=="Toolbar"){// The Toolbar component changed the AppState Message property}}}privateboolenabled=false;publicboolEnabled{
get =>enabled;privateset{enabled=value;}}privateintcounter=0;publicintCounter{
get =>counter;privateset{counter=value;if(Caller=="Counter"){// The Counter page changed the AppState Counter property}}}}In order to determine which mutated the state, we've created private setters for each property.
Properties are set by calling the SetProperty() method.
From the docs at https://learn.microsoft.com/en-us/dotnet/architecture/blazor-for-web-forms-developers/components:
A
RenderFragmentrepresents a chunk of Razor markup that can then be rendered by the component. Razor components can capture their child content as aRenderFragmentand render that content as part of the component rendering. To capture child content, define a component parameter of typeRenderFragmentand name itChildContent.
Using a RenderFragment named ChildContent tells the Blazor component engine that ChildContent is everything in the render tree below this component.
The three property handlers must be explicit so that we can call StateHasChanged() when values are set. That tells the rendering engine that something has changed, and a redraw is necessary.
<CascadingAppState>
<RouterAppAssembly="@typeof(App).Assembly">
<FoundContext="routeData">
<RouteViewRouteData="@routeData"DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigateRouteData="@routeData"Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutViewLayout="@typeof(MainLayout)">
<prole="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAppState><divstyle="height:42px;"><span style="font-size:x-large">@AppState.Message </span>@if(AppState.Enabled){<button class="btn btn-success">Enabled</button>}else{<button class="btn btn-danger">Disabled</button>} <button class="btn btn-primary"@onclick="UpdateMessageButtonClicked">Update Message</button></div>
@code {[CascadingParameter]publicCascadingAppState AppState {get;set;}void UpdateMessageButtonClicked(){AppState.SetProperty(this, "Message", "Message Updated At " +DateTime.Now.ToLongTimeString());}}The Toolbar will go across the top of the page.
Note that we grab the reference to CascadingAppState with the [CascadingParameter] attribute. It's almost the same as injection, except that it's optimized for use in Blazor components and pages.
Now we can refer to AppState and it's properties anywhere in the component. The values of those properties exist OUTSIDE the page, and will still be there if we reload the component.
@inherits LayoutComponentBase
<divclass="page">
<divclass="sidebar">
<NavMenu />
</div>
<main>
<divclass="top-row px-4">
<Toolbar />
</div>
<articleclass="content px-4">
@Body
</article>
</main>
</div>@page "/"<button class="btn btn-primary"@onclick="ToggleEnabledButtonClicked">Toggle Enabled</button><button class="btn btn-primary"@onclick="UpdateMessageButtonClicked">Update Message</button><br /><br /><h3>@AppState.Message</h3><br/><br/>@code{[CascadingParameter]publicCascadingAppState AppState {get;set;}void ToggleEnabledButtonClicked(){
AppState.SetProperty(this,"Enabled",!AppState.Enabled);}void UpdateMessageButtonClicked(){
AppState.SetProperty(this,"Message","Message Updated At "+DateTime.Now.ToLongTimeString());}}Again, we're grabbing that cascading reference to CascadingAppState. Now we have two components with access to it, and they can both get and set it's properties.
Note that we are calling AppState.SetProperty() passing ourself (this), the property name, and the new value.
@page "/counter"<h1>Counter</h1><p>Current count: @AppState.Counter</p><button class="btn btn-primary"@onclick="IncrementCount">Click me</button>
@code {[CascadingParameter]publicCascadingAppState AppState {get;set;}
private void IncrementCount(){AppState.SetProperty(this, "Counter",AppState.Counter+1);}}Here we have replaced currentCount with AppState.Counter.
Put breakpoints on lines 41 and 65 of CascadingAppState.razor
Go to the Counter page, and click the button. Now when it breaks on line 65, you can take action in the toolbar because you know some other component modified the Counter property of the CascadingAppState reference.
Notice that our counter page remembers the value between navigations. That's because it is being stored in the CascadingAppStateProvider!
Click the Update Message button in the Toolbar to test the breakpoint on line 41.
<divstyle="background-color:lightgray;padding:20px;"><button class="btn btn-primary"@onclick="UpdateMessageButtonClicked">Update Message</button><br /><br /><h3>@AppState.Message</h3></div>@code{[CascadingParameter]publicCascadingAppState AppState {get;set;}void UpdateMessageButtonClicked(){AppState.SetProperty(this, "Message", "Child Component Updated Message At "
+ DateTime.Now.ToLongTimeString());}}Index.razor:
@page "/"<button class="btn btn-primary"@onclick="ToggleEnabledButtonClicked">Toggle Enabled</button><button class="btn btn-primary"@onclick="UpdateMessageButtonClicked">Update Message</button><br /><br /><h3>@AppState.Message</h3><br/><br/><ChildComponent/>@code{[CascadingParameter]publicCascadingAppState AppState {get;set;}void ToggleEnabledButtonClicked(){
AppState.SetProperty(this,"Enabled",!AppState.Enabled);}void UpdateMessageButtonClicked(){
AppState.SetProperty(this,"Message","Message Updated At "+DateTime.Now.ToLongTimeString());}}Run the app to confirm that the child component can change AppState values.
It would be great if we could save certain AppState properties so that they will come back the next time the app is run.
We will need access to LocalStorage. For this we will use Chris Sainty's Blazored.LocalStorage pagckage.
Add the following package reference to ApplicationState.Client.csproj:
<PackageReferenceInclude="Blazored.LocalStorage"Version="4.2.0" />Add this to Program.cs
builder.Services.AddBlazoredLocalStorage();You'll need this:
usingBlazored.LocalStorage;Add to _Imports.razor:
@usingBlazored.LocalStorage
@using System.Text.JsonWe are going to separate the properties we want to persist into an interface.
That will allow us to serialize just those properties to JSON.
Add the following class to the client project:
IAppState.cs
publicinterfaceIAppState{intTimeToLiveInSeconds{get;set;}DateTimeLastAccessed{get;set;}stringMessage{get;}boolEnabled{get;}intCounter{get;}}Note that we only require get handlers for each property. That's because our setters are private.
System.Text.Json will not deserialize to an interface, so we have to create a class that implements the interface:
publicclassAppStateData:IAppState{publicintTimeToLiveInSeconds{get;set;}publicDateTimeLastAccessed{get;set;}publicstringMessage{get;set;}="";publicboolEnabled{get;set;}publicintCounter{get;set;}}Now replace CascadingAppState.razor with the following:
@implementsIAppState
@inject ILocalStorageService LocalStorageService
<CascadingValue Value="this">@ChildContent</CascadingValue>@code{[Parameter]public RenderFragment ChildContent {get;set;}
private string Caller ="";publicintTimeToLiveInSeconds{get;set;}=60;publicDateTimeLastAccessed{get;set;}=DateTime.Now;publicvoidSetProperty(ComponentBasecaller,stringPropertyName,objectPropertyValue,boolSaveChanges=true){try{varprop=this.GetType().GetProperty(PropertyName);if(prop!=null){this.Caller=caller.GetType().Name;prop.SetValue(this,PropertyValue);if(SaveChanges)this.SaveChanges();StateHasChanged();}}catch(Exceptionex){}}privatestringmessage="";publicstringMessage{
get =>message;privateset{message=value;if(Caller=="Toolbar"){// The Toolbar component changed the AppState Message property}}}privateboolenabled=false;publicboolEnabled{
get =>enabled;privateset{enabled=value;}}privateintcounter=0;publicintCounter{
get =>counter;privateset{counter=value;if(Caller=="Counter"){// The Counter page changed the AppState Counter property}}}protectedoverrideasyncTaskOnAfterRenderAsync(boolfirstRender){if(firstRender)// do this only once{// Get the JSON datavarjson=awaitLocalStorageService.GetItemAsStringAsync("AppStateJson");if(json!=null&&json!=""){// Deserialize to AppStateDatavarappStateData=JsonSerializer.Deserialize<AppStateData>(json);// Are we within the time window?if(DateTime.Now<=appStateData.LastAccessed.AddSeconds(appStateData.TimeToLiveInSeconds)){// Yes! use our own SetProperty method to set our property valuesvarproperties=appStateData.GetType().GetProperties();foreach(varpropertyinproperties){vartype=property.PropertyType;varvalue=property.GetValue(appStateData);SetProperty(this,property.Name,value,false);}// Re-renderawaitInvokeAsync(StateHasChanged);}}}}publicvoidSaveChanges(){// LocalStorage allows up to 5MB by default.// Test: https://arty.name/localstorage.html// TIP: Never store sensitive data in LocalStorageLastAccessed=DateTime.Now;varappstate=(IAppState)this;varjson=System.Text.Json.JsonSerializer.Serialize(appstate);LocalStorageService.SetItemAsStringAsync("AppStateJson",json);}}Notice that we are implementing IAppState
Check out SaveChanges():
publicvoidSaveChanges(){// LocalStorage allows up to 5MB by default.// Test: https://arty.name/localstorage.html// TIP: Never store sensitive data in LocalStorageLastAccessed=DateTime.Now;varappstate=(IAppState)this;varjson=System.Text.Json.JsonSerializer.Serialize(appstate);LocalStorageService.SetItemAsStringAsync("AppStateJson",json);}This gets called from our property setters.
Any time a property is changed, all the values of all the properties in IAppState will be saved in local storage.
NOTE: Everything still works as before, but now we can persist all the properties exposed by the Interface.
Now check out OnAfterRenderAsync :
protectedoverrideasyncTaskOnAfterRenderAsync(boolfirstRender){if(firstRender)// do this only once{// Get the JSON datavarjson=awaitLocalStorageService.GetItemAsStringAsync("AppStateJson");if(json!=null&&json!=""){// Deserialize to AppStateDatavarappStateData=JsonSerializer.Deserialize<AppStateData>(json);// Are we within the time window?if(DateTime.Now<=appStateData.LastAccessed.AddSeconds(appStateData.TimeToLiveInSeconds)){// Yes! use our own SetProperty method to set our property valuesvarproperties=appStateData.GetType().GetProperties();foreach(varpropertyinproperties){vartype=property.PropertyType;varvalue=property.GetValue(appStateData);SetProperty(this,property.Name,value,false);}// Re-renderawaitInvokeAsync(StateHasChanged);}}}}This happens after the first render.
We're reading the JSON from local storage, deserializing it into an AppStateDataobject (remember, we can't serialize to an interface), and then using reflection to set the values that are part of our interface.
We also added a bool argument to the SetProperty method:
publicvoidSetProperty(ComponentBasecaller,stringPropertyName,objectPropertyValue,boolSaveChanges=true){try{varprop=this.GetType().GetProperty(PropertyName);if(prop!=null){this.Caller=caller.GetType().Name;prop.SetValue(this,PropertyValue);if(SaveChanges)this.SaveChanges();StateHasChanged();}}catch(Exceptionex){}}The SaveChanges argument is true by default, and therefore changes are saved.
However, we call SetProperty with SaveChanges set to false from OnAfterRenderAsync because we are initializing the data from a JSON file, and therefore don't want to save that same JSON file again.
Press F5.
Make some changes
Close the app right after making a change, and then run it again within 60 seconds. The AppState will load from LocalStorage:
If you close the app and wait at least 60 seconds before running it again, the AppState gets reset and you're back to square one.
The IAppStae interface and the AppStateData class determine which properties get persisted, and you can modify them at will. The only requirement is that the properties must be implemented in CascadingAppState.razor.
Try it! Remove a property from both IAppState and AppStateData. Run the app and test the persistence.




