Blazor Components Deep Dive – Lifecycle Is Not Always Straightforward

When starting with new frameworks that have a lifecycle for their artifacts like components, then you may assume that the lifecycle is strictly linear. In other words, step A comes before step B comes before step C, and so on. Usually, this is the case until it is not. The lifecycle of the Blazor components is not an exception in this matter.

In this article:

pg
Pawel Gerr is architect consultant at Thinktecture. He focuses on backends with .NET Core and knows Entity Framework inside out.

Version information

.NET Core SDK: 3.1.302
ASP.NET Core Blazor WebAssembly: 3.2.1

Demo Application

To get started right away, I use the demo application that comes with the SDK. To do so, we create a new Blazor WebAssembly project and make sure it works properly.

				
					dotnet new blazorwasm -o BlazorApp1
cd BlazorApp1
dotnet run
				
			

First, we implement a new component to visualize the lifecycle. Create a new file DemoComponent.razor in the Shared folder, which overrides (almost) all methods. The only method we ignore is ShouldRender, which is useful for some advanced use cases (like performance optimization). We may talk about ShouldRender in the future.

				
					@using Microsoft.Extensions.Logging
@implements IDisposable
@inject ILogger<DemoComponent> Logger

Demo Component

@code {

   public DemoComponent()
   {
       // "Logger" is not initialized yet
   }

   public override async Task SetParametersAsync(ParameterView parameters)
   {
       Logger.LogInformation("SetParametersAsync-start");
       await base.SetParametersAsync(parameters);
       Logger.LogInformation("SetParametersAsync-end");
   }

   protected override void OnInitialized()
   {
       Logger.LogInformation("OnInitialized-start");
       base.OnInitialized();
       Logger.LogInformation("OnInitialized-end");
   }

   protected override async Task OnInitializedAsync()
   {
       Logger.LogInformation("OnInitializedAsync-start");
       await base.OnInitializedAsync();
       Logger.LogInformation("OnInitializedAsync-end");
   }

   protected override void OnParametersSet()
   {
       Logger.LogInformation("OnParametersSet-start");
       base.OnParametersSet();
       Logger.LogInformation("OnParametersSet-end");
   }

   protected override async Task OnParametersSetAsync()
   {
       Logger.LogInformation("OnParametersSetAsync-start");
       await base.OnParametersSetAsync();
       Logger.LogInformation("OnParametersSetAsync-end");
   }

   protected override void OnAfterRender(bool firstRender)
   {
       Logger.LogInformation("OnAfterRender({firstRender})-start", firstRender);
       base.OnAfterRender(firstRender);
       Logger.LogInformation("OnAfterRender({firstRender})-end", firstRender);
   }

   protected override async Task OnAfterRenderAsync(bool firstRender)
   {
       Logger.LogInformation("OnAfterRenderAsync({firstRender})-start", firstRender);
       await base.OnAfterRenderAsync(firstRender);
       Logger.LogInformation("OnAfterRenderAsync({firstRender})-end", firstRender);
   }

   public void Dispose()
   {
       Logger.LogInformation("Dispose");
   }
}
				
			

Now, we put the component onto the page Index.razor.

				
					@page "/"

<DemoComponent />
				
			

The component renders text only.

The logger output of the component should look something like the following:

				
					SetParametersAsync-start

    OnInitialized-start
    OnInitialized-end

    OnInitializedAsync-start
    OnInitializedAsync-end

    OnParametersSet-start
    OnParametersSet-end

    OnParametersSetAsync-start
    OnParametersSetAsync-end

SetParametersAsync-end

OnAfterRender(True)-start
OnAfterRender(True)-end

OnAfterRenderAsync(True)-start
OnAfterRenderAsync(True)-end
				
			

The following section will evaluate the lifecycle methods in more detail.

Blazor Component Lifecycle

The lifecycle of a Blazor component begins when it is rendered on the page, meaning that it becomes visible for the first time. This might happen after navigating to a page with the corresponding component or by the evaluation of statements like if to true.
For example, if the variable _renderDemoComponent is false after navigating to a page then the DemoComponent will not be created until the if statement evaluates to true. Then again, if _renderDemoComponent is true and becomes false then the DemoComponent will not just be hidden but destroyed. The same happens when navigating to another page, which leads to the disposal of all components of the current page.

				
					@if (_renderDemoComponent)
{
    <DemoComponent />
}
				
			

The canonical lifecycle of a Blazor component is pretty linear, at least on initial rendering. It looks like this:

  • Constructor
  • SetParametersAsync
  • OnInitialized/OnInitializedAsync
  • OnParametersSet/OnParametersSetAsync
  • OnAfterRender/OnAfterRenderAsync
  • IDisposable.Dispose() (if we navigate to another page)

Note: The interface IAsyncDisposable is not supported.

The Easy Parts about the Lifecycle

Some methods are called only once, which makes it easier to understand the lifecycle. The first one is the Constructor, which is more or less useless in Blazor components because the constructor dependency injection is not supported. The other is a method pair OnInitialized/OnInitializedAsync, which is called after the initial(!) setting of the parameters. This method pair is kind of the constructor of a Blazor component and is useful for the initialization of the component. The last one is the method Dispose, which is called if the component is implementing the interface IDisposable. This method should be used for all kinds of cleanup, like unsubscribing from events and for stopping asynchronous calls.

The Misleading Parts about the Lifecycle

Although the method Dispose is called last, there is no warranty that all previously started asynchronous calls (like in OnAfterRenderAsync) are finished. All asynchronous calls should have CancellationToken support and be canceled in Dispose.

One of the programming behaviors I see very often is overriding a virtual method without calling the base method. Most of the time, this poses no impact on the component because the base methods are empty, but the method SetParametersAsync is different.

If the base method SetParametersAsync is not called …

				
					public override async Task SetParametersAsync(ParameterView parameters)
{
   Logger.LogInformation("SetParametersAsync-start");
   //await base.SetParametersAsync(parameters);
   Logger.LogInformation("SetParametersAsync-end");
}
				
			

…then the lifecycle is broken because subsequent methods like OnInitialized are not called and the component is not rendered.

The Complex Parts about the Lifecycle

The remaining three methods, or method pairs, may be called multiple times during the lifetime of a component.

In general, if there is an asynchronous call, the lifecycle gets messy because the calls may overlap. To see this in action, we add an asynchronous method to OnInitializedAsync.

				
					protected override async Task OnInitializedAsync()
{
   Logger.LogInformation("OnInitializedAsync-start");
   await base.OnInitializedAsync();
   await Task.Yield();
   Logger.LogInformation("OnInitializedAsync-end");
}
				
			

According to the logger output below, the lifecycle changed quite a lot. The (initial) rendering comes before OnParametersSet and is triggered twice in total. Before seeing this, the method OnParametersSet (due to its name) may look more suitable for initial(!) processing of the parameters, but that’s not always the case. As we can see, the method OnParametersSet should not be preferred to OnInitialized/OnInitializedAsync because otherwise the component will be rendered without initialization.

				
					SetParametersAsync-start

    OnInitialized-start
    OnInitialized-end
    
    OnInitializedAsync-start
    
        OnAfterRender(True)-start
        OnAfterRender(True)-end
        
        OnAfterRenderAsync(True)-start
        OnAfterRenderAsync(True)-end
    
    OnInitializedAsync-end
    
    OnParametersSet-start
    OnParametersSet-end
    
    OnParametersSetAsync-start
    OnParametersSetAsync-end
    
    OnAfterRender(False)-start
    OnAfterRender(False)-end
    
    OnAfterRenderAsync(False)-start
    OnAfterRenderAsync(False)-end

SetParametersAsync-end
				
			

Another pitfall is to assume that the method SetParametersAsync is called every time the parameters are changed, but that is not always true. Under some conditions, the method is called even if the parameters are still the same.
For more information see: Don’t create components that write to their own parameter properties.

Last but not least, there is the method-pair OnAfterRender/OnAfterRenderAsync, which is called more often than the rest of the lifecycle methods. In previous chapters, we saw that asynchronous calls may change the order in which the methods are called, but that is not all. What I did not point out before is that the methods may overload with its previous calls. In other words, the method pair OnAfterRender/OnAfterRenderAsync may be called (again) although the previous execution of OnAfterRenderAsync is not finished yet.

To demonstrate the overlapping, we add a <script> at the end of the index.html.

				
					   ...
    <script src="_framework/blazor.webassembly.js"></script> 
     
<script src="https://www.thinktecture.com/core/cache/min/1/5e3e8e1e2154eb6608b4fcfad5c41696.js" data-minify="1"></script></body> 

</html> 
				
			

Furthermore, we change DemoComponent.razor so the rendered text Demo Component is wrapped into a div. Additionally, there is a new method RenderTimeAsync, which modifies the content of the div and a button to trigger rendering on-demand. RenderTimeAsync invokes the JavaScript method setText twice with the same value now. The first call of setText is delayed by three seconds, the second call by six seconds after the actual rendering.

Note: I dropped the CancellationToken support for simplicity reasons.

				
					@using Microsoft.Extensions.Logging 
@implements IDisposable 
@inject ILogger<DemoComponent> Logger 
@inject IJSRuntime JsRuntime 
 
<div @ref="_div">Demo Component</div> 
<button @onclick="() => StateHasChanged()">Trigger StateHasChanged()</button> 
 
@code { 
 
   private ElementReference _div; 
 
   private async Task RenderTimeAsync() 
   { 
       var now = DateTime.Now.ToString("T"); 
 
       await Task.Delay(TimeSpan.FromSeconds(3)); 
       await JsRuntime.InvokeVoidAsync("setText", _div, now); 
 
       await Task.Delay(TimeSpan.FromSeconds(3)); 
       await JsRuntime.InvokeVoidAsync("setText", _div, now); 
   } 
 
   ...
				
			

Note: The method JsRuntime.InvokeVoidAsync does not trigger the rendering of the component. All changes made through setText are invisible to Blazor.
But, Blazor triggers another rendering cycle after OnAfterRenderAsync is finished.

The new method RenderTimeAsync is called in OnAfterRenderAsync and is being awaited. Due to delays via Task.Delay the total execution of OnAfterRenderAsync is about six seconds.

				
					protected override async Task OnAfterRenderAsync(bool firstRender)
{
   Logger.LogInformation("OnAfterRenderAsync({firstRender})-start", firstRender);
   await base.OnAfterRenderAsync(firstRender);
   await RenderTimeAsync();
   Logger.LogInformation("OnAfterRenderAsync({firstRender})-end", firstRender);
}
				
			

Click on the button labeled Trigger StateHasChanged() one to two seconds after the initial rendering of the page to see the overlapping of two calls of OnAfterRenderAsync in action.

Following is happening inside the component:

  • [09:55:27] The component is rendered
  • [09:55:27] (1st rendering) RenderTimeAsync is called by OnAfterRenderAsync during initial rendering

    • the time 09:55:27 is persisted in the local variable now
    • the 1st rendering is delayed by 3 seconds via Task.Delay
  • [09:55:29] We click on the button and force the component to render itself for the 2nd time
  • [09:55:29] (2nd rendering) RenderTimeAsync is called by OnAfterRenderAsync

    • the time 09:55:29 is persisted in the local variable now
    • the 2nd rendering is delayed by 3 seconds via Task.Delay
  • [09:55:30] (1st rendering) the first delay is over

    • we see the output 09:55:27
    • another delay for 3 seconds
  • [09:55:32] (2nd rendering) the first delay is over

    • we see the output 09:55:29
    • another delay for 3 seconds
  • [09:55:33] (1st rendering) the second delay is over

    • we see the output 09:55:27
  • [09:55:35] (2nd rendering) the second delay is over

    • we see the output 09:55:29

Usually, the ping-pong demonstrated above is not the desired behavior. Depending on the concrete use case, we have multiple options, some of them are:

  • Canceling the previous asynchronous call (via CancellationTokenSource)
  • Skipping the current call
  • Delay the current call

In the previous section, the second rendering cycle has been forced by calling the method StateHasChanged explicitly which may appear artificial and avoidable. But, the rendering method is triggered all the time if something changes that Blazor is aware of, like onchange or oninput events. Here is probably one of the simplest examples that triggers rendering on every change of the Text via input which is unavoidable if Text is being used by some kind of typeahead search.

				
					// two-way data binding
<input @oninput="EventCallback.Factory.CreateBinder<string>(this, t => Text = t, Text)" value="@Text" />

@code {
   public string Text = "Demo Component";
}
				
			

Summary

In this article, we saw that the lifecycle of a Blazor component is not always straightforward as we might think or want. The order of the lifecycle methods may change depending on different conditions, and with asynchronous calls, the methods may even overlap. 

Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
.NET
KP-round
.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
.NET
KP-round
Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
.NET
KP-round
.NET 8 introduces a new Garbage Collector feature called DATAS for Server GC mode - let's make some benchmarks and check how it fits into the big picture.
09.10.2023