Avoid async calls in view model constructors
The view models in the MVVM pattern are responsible for providing data for the view. It may be tempting to load the data in the constructor of the view model. But if the methods to load the data are asynchronous, they must then be called in a blocking manner:
[ObservableObject]
public partial class MainViewModel
{
[ObservableProperty]
private List<string> items = new();
public MainViewModel(DataService dataService)
{
items = dataService.LoadItems().Result;
}
}
Even if we ignore the danger of deadlock, this is not a good idea:
- Loading the data can take a while, and until it completes, the view model is not instantiated and is not available to the view, making the application appear unresponsive and slow.
- If loading fails, the application may crash if the exception is not handled properly.
It is much better to load the data in an asynchronous initialization method and call that method from the constructor without blocking, so that the view model can be instantiated before loading is complete:
[ObservableObject]
public partial class MainViewModel
{
private readonly DataService dataService;
private readonly Task initTask;
[ObservableProperty]
private List<string> items = new();
public MainViewModel(DataService dataService)
{
this.dataService = dataService;
this.initTask = InitAsync();
}
private async Task InitAsync()
{
Items = await dataService.LoadItems();
}
}
This approach can easily be extended with a loading flag:
[ObservableProperty]
private bool loading = true;
private async Task InitAsync()
{
Items = await dataService.LoadItems();
Loading = false;
}
which can be used in the view to display an activity indicator:
<ActivityIndicator IsRunning="True"
IsVisible="{Binding Loading}" />
Without error handling, the activity indicator will continue to run indefinitely if the data cannot be loaded. However, it is easy enough to add basic error handling:
[ObservableProperty]
private bool hasError = false;
[RelayCommand]
private async Task InitAsync()
{
Loading = true;
HasError = false;
try
{
Items = await dataService.LoadItems();
}
catch
{
HasError = true;
}
finally
{
Loading = false;
}
}
Now an error message can be displayed to the user with an option to retry loading the data:
<VerticalStackLayout IsVisible="{Binding HasError}">
<Label Text="Loading failed" />
<Button Text="Retry"
Command="{Binding InitCommand}" />
</VerticalStackLayout>
If you do not like the non-blocking asynchronous method call in the constructor, you can instead call the initialization method via an appropriate view event. In .NET MAUI, you can install the .NET MAUI Community Toolkit to do this directly in XAML with a behavior:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:Class="MauiAsyncViewModelInit.MainPage">
<ContentPage.Behaviors>
<toolkit:EventToCommandBehavior EventName="Loaded"
Command="{Binding InitCommand}" />
</ContentPage.Behaviors>
If you do this, do not forget to remove the method call from the constructor:
public MainViewModel(DataService dataService)
{
this.dataService = dataService;
}
You can find a working example in my GitHub repository. The individual commits represent the steps from the blocking call in the constructor to the final solution as described above.
It's never a good idea to make blocking asynchronous calls, as they can lead to deadlocks and other problems. It is worth making the effort to avoid this. In this post, I described how to move such blocking asynchronous calls from a view model constructor to a separate asynchronous initialization method.