Reputation: 2991
I have a component that accepts an int parameter - which the component uses to make an API call to retrieve some data. This logic is currently in the component's OnParametersSetAsync()
.
This component also has a complex-typed parameter.
When this component is used by a parent component that re-renders itself for various reasons, OnParametersSetAsync()
is called on this child component - even if none of its parameters have changed. My understanding is that this is because of the complex-type parameter (blazor can't tell if it changed, so it assumes it did).
This results in the API call retriggering needlessly (the actual int parameter didn't change).
Is doing data-retrieval like this not appropriate for OnParametersSetAsync()
? If so, how should I change my components to work with the Blazor framework?
Parent Component
Call to ChangeName() triggers the re-render of the parent
<div>
<EditForm Model="favoriteNumber">
<InputSelect @bind-Value="favoriteNumber">
<option value="0">zero</option>
<option value="1">one</option>
<option value="2">two</option>
<option value="3">three</option>
</InputSelect>
</EditForm>
@* This is the child-component in question *@
<TestComponent FavoriteNumber="favoriteNumber" FavoriteBook="favoriteBook" />
<br />
<EditForm Model="person">
First Name:
<InputText @bind-Value="person.FirstName" />
<br />
Last Name:
<InputText @bind-Value="person.LastName" />
</EditForm>
<button @onclick="ChangeName">Change Name</button>
</div>
@code {
private int favoriteNumber = 0;
private Book favoriteBook = new();
private Person person = new() { FirstName = "Joe", LastName = "Smith" };
private void ChangeName()
{
person.FirstName = person.FirstName == "Susan" ? "Joe" : "Susan";
person.LastName = person.LastName == "Smith" ? "Williams" : "Smith";
}
}
Child Component
<div>@infoAboutFavoriteNumber</div>
@code {
[Parameter]
public int FavoriteNumber { get; set; }
[Parameter]
public Book FavoriteBook { get; set; }
private string infoAboutFavoriteNumber = "";
protected override async Task OnParametersSetAsync()
{
infoAboutFavoriteNumber = await ApiService.GetAsync<string>(id: FavoriteNumber.ToString());
}
}
Upvotes: 3
Views: 8716
Reputation: 30046
You're facing a common problem: doing data and data access activity in the UI. Things tend to get messy! In this answer I've separated the data from the components. The data and data access reside in a Dependancy Injection service.
I've also done away with EditForm
as you aren't actually using it, and changed the Select
to a simple select so we can capture updates, update the model and trigger the data retrieval in the service. This also means that the component gets re-rendered after the model has updated. The Blazor UI event handler for the OnChanged
event calls StateHasChanged
after calling NumberChanged
.
First a class for our Favourites data.
public class MyFavourites
{
public int FavouriteNumber { get; set; }
public string FavouriteNumberInfo { get; set; } = string.Empty;
}
Second a DI service to hold our Favourites data and datastore operations.
namespace Server;
public class MyFavouritesViewService
{
public MyFavourites Favourites { get; private set; } = new MyFavourites();
public async Task GetFavourites()
{
// Emulate a database get
await Task.Delay(100);
Favourites = new MyFavourites { FavouriteNumber = 2, FavouriteNumberInfo = "The number is 2" };
}
public async Task SaveFavourites()
{
// Emulate a database save
await Task.Delay(100);
// Save code here
}
public async Task GetNewNumberInfo(int number)
{
if (number != Favourites.FavouriteNumber)
{
// Emulate a database get
await Task.Delay(100);
Favourites.FavouriteNumberInfo = $"The number is {number}";
Favourites.FavouriteNumber = number;
}
}
}
Next register the Service in Program:
builder.Services.AddScoped<MyFavouritesViewService>();
The component:
<h3>MyFavouriteNumber is @this.Favourites.FavouriteNumber</h3>
<h3>MyFavouriteNumber info is @this.Favourites.FavouriteNumberInfo</h3>
@code {
[Parameter][EditorRequired] public MyFavourites Favourites { get; set; } = new MyFavourites();
}
And finally the page. Note I'm using OwningComponentBase
to tie the scope of MyFavouritesViewService
to the component lifecycle.
@page "/favourites"
@page "/"
@inherits OwningComponentBase<MyFavouritesViewService>
@namespace Server
<h3>Favourite Number</h3>
<div class="p-5">
<select class="form-select" @onchange="NumberChanged">
@foreach (var option in options)
{
if (option.Key == this.Service.Favourites.FavouriteNumber)
{
<option selected value="@option.Key">@option.Value</option>
}
else
{
<option value="@option.Key">@option.Value</option>
}
}
</select>
<div>
<button class="btn btn-success" @onclick="SaveFavourites">Save</button>
</div>
</div>
<MyFavouriteNumber Favourites=this.Service.Favourites />
@code {
private Dictionary<int, string> options = new Dictionary<int, string>
{
{0, "Zero"},
{1, "One"},
{2, "Two"},
{3, "Three"},
};
// Use OnInitializedAsync to get the original values from the data store
protected async override Task OnInitializedAsync()
=> await this.Service.GetFavourites();
// Demo to show saving
private async Task SaveFavourites()
=> await this.Service.SaveFavourites();
// Async setup ensures GetNewNumberInfo runs to completion
// before StatehasChanged is called by the Handler
// Renderer the checks what's changed and calls SetParamaterAsync
// on MyFavouriteNumber because FavouriteNumber has changed
private async Task NumberChanged(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out int value))
await this.Service.GetNewNumberInfo(value);
}
}
Upvotes: 1
Reputation: 45626
which the component uses to make an API call to retrieve some data.
Your child component should not perform any API calls. It is the parent component that should manage the state of the parent itself and its children, downstreaming
the data. If things get complicated, then you'll have to implement a service that handle state. @Peter Morris would certainly advise you to use Blazor State Management Using Fluxor.
Not sure why you use two EditForm components, when you actually should use none. Realize that components are very expensive, and they make your code slow. So use it wisely
Answering your question:
Define a local field to hold the FavoriteNumber parameter property's value as follows, in the child component:
@code
{
[Parameter]
public int FavoriteNumber { get; set; }
private int FavoriteNumberLocal = -1;
}
Note: The FavoriteNumberLocal variable stores the value passed from the parent component. It allows you to locally store and check if its value has changed, and accordingly decide whether to call the Web Api end point or not (Again, you shouldn't do that like this)
protected override async Task OnParametersSetAsync()
{
if( FavoriteNumberLocal != FavoriteNumber)
{
FavoriteNumberLocal = FavoriteNumber;
infoAboutFavoriteNumber = await ApiService.GetAsync<string>(id:
FavoriteNumberLocal.ToString());
}
}
Read the last two comments to this question
Upvotes: 4
Reputation: 27348
You can introduce local field and compare its value like other suggests or catch the old value before it changes in SetParametersAsync
and it will work in basic scenarios.
However, what if:
Reactive Extensions (IObservable) is designed to deal exactly with such scenarios. In Angular (very simmilar to Blazor), the RxJS is first class citizen.
In Blazor, just turn the parameter into IObservable, use RX Operators to deal with it without introducing your own local variables.
readonly Subject<Unit> _parametersSet = new ();
protected override Task OnParametersSetAsync()
{
_parametersSet.OnNext(Unit.Default); //turn OnParametersSetAsync into Observable stream
return base.OnParametersSetAsync();
}
[Parameter] public int FavoriteNumber { get; set; }
protected override void OnInitialized()
{
_parametersSet.Select(_ => FavoriteNumber) //turn parameter into Observable
.DistinctUntilChanged() //detect changes
.Select(value => Observable.FromAsync(cancellationToken =>
{
Console.WriteLine($"FavoriteNumber has changed: {value}");
infoAboutFavoriteNumber = await ApiService.GetAsync(value, cancellationToken);
})
.Switch() //take care of concurrency
.Subscribe();
}
The nice thing about it, that you can create a reusable class or helper method with all the boilerplate. You just specify a parameter and the async method, e.g:
Loader.Create(ObserveParameter(() => FavoriteNumber), LoadAsync);
for more reading, check:
this article: https://blog.vyvojari.dev/blazor-take-advantage-of-system-reactive-aka-observables-part-2/
live demo: https://blazorrepl.telerik.com/QQullYbo21fLFclq27
this question: How to add "reload" and IsLoading status to 2nd level Observable
Upvotes: 1
Reputation: 273244
You can implement your own state logic with a private int.
A lot cheaper than calling an API again.
<div>@infoAboutFavoriteNumber</div>
@code {
[Parameter]
public int FavoriteNumber { get; set; }
[Parameter]
public Book FavoriteBook { get; set; }
private string infoAboutFavoriteNumber = "";
private int currentNumber = -1; // some invalid value
protected override async Task OnParametersSetAsync()
{
if (currentNumber != FavoriteNumber)
{
currentNumber = FavoriteNumber;
infoAboutFavoriteNumber = await ApiService.GetAsync<string>(id: FavoriteNumber.ToString());
}
}
}
Upvotes: 4
Reputation: 188
I don't think that this is necessarily a poor practice to put this logic into the OnParametersSetAsync()
method. But there is a way to prevent it from making so many API calls. I would create a private variable that stores the value of the public parameter and then every time the OnParametersSetAsync()
method is called you compare the two variables and if they are the same, then you don't make the API call, if they are different, then you make the API call, and after it finishes, you assign the private variable to the public parameter's value. To account for the very first time the component calls the method I would probably assign the private variable to default to a -1
, as typically ID
values aren't negative. But basically I would assign it to a value that would never be equal to any value passed as the parameter. Otherwise the first time it is called your API might not actually get called. Here is an example:
<div>@infoAboutFavoriteNumber</div>
@code {
[Parameter]
public int FavoriteNumber { get; set; }
private int CurrentFavoriteNumber { get; set; } = -1;
[Parameter]
public Book FavoriteBook { get; set; }
private string infoAboutFavoriteNumber = "";
protected override async Task OnParametersSetAsync()
{
if (FavoriteNumber != CurrentFavoriteNumber)
{
infoAboutFavoriteNumber = await ApiService.GetAsync<string>(id: FavoriteNumber.ToString());
CurrentFavoriteNumber = FavoriteNumber;
}
}
}
Upvotes: 2