Reputation: 73
I have a project in which I frequently use OnAfterRender() to call methods on child components. As a simple example, suppose I have two Blazor components, Counter.razor and CounterParent.razor:
Counter:
@page "/counter"
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
// This method is private out of the box,
// but let's change it to public for this example
public void IncrementCount()
{
currentCount++;
StateHasChanged();
}
}
CounterParent:
@page "/counterParent"
<h3>Counter Parent</h3>
<Counter @ref="counterChild"></Counter>
@code {
private Counter counterChild;
private bool loaded;
protected override void OnAfterRender(bool firstRender)
{
if (!loaded && counterChild != null)
{
counterChild.IncrementCount(); // Do some operation on the child component
loaded = true;
}
}
}
The parent component in this example causes the child component (the counter) to be incremented by 1.
However, I have recently been advised that this is not a good practice, and that
On OnAfterRender{async} usage, it should (in general) only be used to do JS interop stuff.
So if this is poor practice what is the best practice for calling methods on child components when a page loads? Why might someone choose to
actually either disable it on ComponentBase based components, , or don't implement it on [their] own components?
What I tried:
Code like the above works. It is definitely slow and the user often sees an ugly mess as things load. I probably won't fix this current project (works well enough for what I need it to), but if there's a better way I'd like to be better informed.
Upvotes: 3
Views: 2377
Reputation: 30450
Separate out the data from the UI code and you get a state object and a component.
public class CounterState
{
public event EventHandler? CounterUpdated;
public int Counter { get; private set; }
// Contrived as Async as in real coding there may well be async calls to DbContexts or HttpClient
public async ValueTask IncrementCounterAsync(object sender)
{
await Task.Delay(150);
Counter = Counter + this._appConfiguration.IncrementRate;
CounterUpdated?.Invoke(sender, EventArgs.Empty);
}
}
@page "/countercomponent"
@implements IDisposable
<p role="status">Current count: @this.State.Counter</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
// Need to define how we get this and it's nullability
private CounterState State { get; set; }
protected override void OnInitialized()
=> this.State.CounterUpdated += this.OnCounterUpdated;
private void OnCounterUpdated(object? sender, EventArgs e)
{
if (sender != this)
StateHasChanged();
}
public async Task IncrementCount(object sender)
=> await this.State.IncrementCounterAsync(sender);
public void Dispose()
=> this.State.CounterUpdated -= this.OnCounterUpdated;
}
The parent:
@page "/counter"
<h3>Counter Parent</h3>
<SimpleCounter />
@code {
// Need to define how we get this and it's nullability
private CounterState State { get; set; }
protected async override Task OnInitializedAsync()
=> await this.State.IncrementCounterAsync(this);
}
The simplest implementation is to register CounterState
as a scoped Service:
builder.Services.AddScoped<CounterState>();
And then inject it into our two components:
[Inject] private CounterState State { get; set; } = default!;
This maintains state within the SPA session. Navigate away and return and the old count is retained. However, this is often too wide a scope.
Cascading restricts the scope of the state object to component and sub-component scope.
Capture the cascaded value in the children:
[CascadingParameter] private CounterState State { get; set; } = default!;
How and what you cascade depends on the dependancies and disposal requirements of the state object.
With no commitments, simply create an instance and cascade it.
<CascadingValue Value="State" IsFixed>
//...
<SimpleCounter />
</CascadingValue>
//....
private CounterState State { get; set; } = new();
If you have DI dependancies then you need to create the state instance in the context of the DI container. With no disposal requirements you can set the scope to Transient
and get a new instance by injection.
However, if the object requires disposal, you need to use ActivatorUtilities
to create an instance outside the Service Container, but in the context of the container to populate dependancies.
@page "/counter"
@inject IServiceProvider ServiceProvider
@implements IDisposable
@implements IAsyncDisposable
<h3>Counter Parent</h3>
<CascadingValue Value="State" IsFixed>
<CounterComponent />
</CascadingValue>
@code {
private CounterState State { get; set; } = default!;
// Run before any render takes place, so the cascaded value is the correct instance
protected override void OnInitialized()
=> this.State = ActivatorUtilities.CreateInstance<CounterState>(ServiceProvider);
protected async override Task OnInitializedAsync()
=> await this.State.IncrementCounterAsync(this);
// Demonstrates how to implement Dispose when you don't know if your object implements IDisposable
public void Dispose()
{
if (this.State is IDisposable disposable)
disposable.Dispose();
}
// Demonstrates how to implement Dispose when you don't know if your object implements IAsyncDisposable
public async ValueTask DisposeAsync()
{
if (this.State is IAsyncDisposable disposable)
await disposable.DisposeAsync();
}
}
Note the cascade is set as IsFixed
to ensure it doesn't cause RenderTree Cascades.
If you don't use OnAfterRender you can short circuit it (and save running a few lines of code) like this.
@implements IHandleAfterRender
//...
Task IHandleAfterRender.OnAfterRenderAsync()
=> Task.CompletedTask;
Upvotes: 1
Reputation: 273844
The core problem here is @ref="counterChild"
, that reference will not yet be set in OnInitialized or (the first) OnParametersSet of the parent.
"it should only be used to do JS interop stuff" is too strict, it should be used for logic that needs the render to be completed. Making use of a component reference qualifies too.
I would use one of:
Put this action in OnInitialized() of the child component. (Assumes you always run the same action on a new child).
trigger it by a parameter on the Child component. That could a simple IsLoaded boolean. Take action in OnParametersSet() of the child.
Keep it in OnAfterRender() but use firstRender
instead of loading
.
That last one is exactly what you already have and it's not totally wrong. But it automatically means you get 2 renders, not the best U/X.
Upvotes: 2
Reputation: 1253
It's better to use OnParametersSet
instead of OnAfterRender
.
OnAfterRender
is called after each rendering of the component. At this stage, the task of loading the component, receiving information and displaying them is finished. One of its uses is the initialization of JavaScript components that require the DOM to work; Like displaying a Bootstrap modal.
Note: any changes made to the field values in these events are not applied to the UI; Because they are in the final stage of UI rendering.
OnParameterSet
called once when the component is initially loaded and again whenever the child component receives a new parameter from the parent component.
Upvotes: 1