Introduction
To invoke an async operation when a two-way bound ComboBox selection changes in WPF, use an async property setter in the ViewModel that calls an async void or async Task method when the bound property changes. The key challenge is that property setters cannot be async, so the setter must fire an async method without awaiting it directly, or use ICommand with an async execute handler. The MVVM pattern keeps async logic in the ViewModel while the ComboBox binding remains standard.
Basic Two-Way Bound ComboBox
1<!-- MainWindow.xaml -->
2<ComboBox ItemsSource="{Binding Categories}"
3 SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
4 DisplayMemberPath="Name" />
5
6<TextBlock Text="{Binding StatusText}" />
7<ItemsControl ItemsSource="{Binding Products}" />
1// MainViewModel.cs
2public class MainViewModel : INotifyPropertyChanged
3{
4 private string _selectedCategory;
5 private string _statusText;
6
7 public ObservableCollection<string> Categories { get; }
8 = new() { "Electronics", "Books", "Clothing" };
9
10 public ObservableCollection<Product> Products { get; } = new();
11
12 public string SelectedCategory
13 {
14 get => _selectedCategory;
15 set
16 {
17 if (_selectedCategory != value)
18 {
19 _selectedCategory = value;
20 OnPropertyChanged();
21 // Fire async operation
22 _ = LoadProductsAsync(value);
23 }
24 }
25 }
26
27 public string StatusText
28 {
29 get => _statusText;
30 set { _statusText = value; OnPropertyChanged(); }
31 }
32
33 private async Task LoadProductsAsync(string category)
34 {
35 StatusText = "Loading...";
36 Products.Clear();
37
38 var products = await _productService.GetByCategoryAsync(category);
39
40 foreach (var product in products)
41 Products.Add(product);
42
43 StatusText = $"Loaded {products.Count} products";
44 }
45
46 public event PropertyChangedEventHandler PropertyChanged;
47 protected void OnPropertyChanged([CallerMemberName] string name = null)
48 => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
49}
The Discard Pattern (_ = Task)
The setter uses _ = LoadProductsAsync(value) because property setters cannot be async:
1public string SelectedCategory
2{
3 get => _selectedCategory;
4 set
5 {
6 _selectedCategory = value;
7 OnPropertyChanged();
8
9 // Cannot use 'await' in a setter — fire and forget with discard
10 _ = LoadProductsAsync(value);
11 }
12}
The _ discard suppresses the compiler warning about unawaited tasks. However, this means exceptions are not observed unless handled inside LoadProductsAsync.
Safe Async with Error Handling
Always wrap the async operation in try/catch:
1private async Task LoadProductsAsync(string category)
2{
3 try
4 {
5 IsLoading = true;
6 StatusText = "Loading...";
7 Products.Clear();
8
9 var products = await _productService.GetByCategoryAsync(category);
10
11 foreach (var product in products)
12 Products.Add(product);
13
14 StatusText = $"{products.Count} products loaded";
15 }
16 catch (HttpRequestException ex)
17 {
18 StatusText = $"Network error: {ex.Message}";
19 }
20 catch (Exception ex)
21 {
22 StatusText = $"Error: {ex.Message}";
23 }
24 finally
25 {
26 IsLoading = false;
27 }
28}
Using ICommand with AsyncRelayCommand
For cleaner MVVM, use AsyncRelayCommand from CommunityToolkit.Mvvm:
1using CommunityToolkit.Mvvm.ComponentModel;
2using CommunityToolkit.Mvvm.Input;
3
4public partial class MainViewModel : ObservableObject
5{
6 [ObservableProperty]
7 private string _selectedCategory;
8
9 [ObservableProperty]
10 private ObservableCollection<Product> _products = new();
11
12 partial void OnSelectedCategoryChanged(string value)
13 {
14 // Auto-generated by [ObservableProperty] — called when SelectedCategory changes
15 LoadProductsCommand.Execute(value);
16 }
17
18 [RelayCommand]
19 private async Task LoadProducts(string category)
20 {
21 Products.Clear();
22 var products = await _productService.GetByCategoryAsync(category);
23 foreach (var p in products)
24 Products.Add(p);
25 }
26}
Cancellation Support
Cancel the previous operation when the user selects a new category quickly:
1public class MainViewModel : INotifyPropertyChanged
2{
3 private CancellationTokenSource _cts;
4 private string _selectedCategory;
5
6 public string SelectedCategory
7 {
8 get => _selectedCategory;
9 set
10 {
11 _selectedCategory = value;
12 OnPropertyChanged();
13 _ = LoadProductsAsync(value);
14 }
15 }
16
17 private async Task LoadProductsAsync(string category)
18 {
19 // Cancel previous operation
20 _cts?.Cancel();
21 _cts = new CancellationTokenSource();
22 var token = _cts.Token;
23
24 try
25 {
26 StatusText = "Loading...";
27 await Task.Delay(300, token); // Debounce
28
29 token.ThrowIfCancellationRequested();
30 var products = await _productService.GetByCategoryAsync(category, token);
31
32 token.ThrowIfCancellationRequested();
33 Products.Clear();
34 foreach (var p in products)
35 Products.Add(p);
36
37 StatusText = $"Loaded {products.Count} items";
38 }
39 catch (OperationCanceledException)
40 {
41 // Expected — user selected a different category
42 }
43 catch (Exception ex)
44 {
45 StatusText = $"Error: {ex.Message}";
46 }
47 }
48}
Loading Indicator with IsBusy
1// ViewModel
2private bool _isLoading;
3public bool IsLoading
4{
5 get => _isLoading;
6 set { _isLoading = value; OnPropertyChanged(); }
7}
1<!-- XAML -->
2<ComboBox ItemsSource="{Binding Categories}"
3 SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
4 IsEnabled="{Binding IsLoading, Converter={StaticResource InverseBoolConverter}}" />
5
6<ProgressBar IsIndeterminate="True"
7 Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}" />
EventToCommand Alternative
Use System.Windows.Interactivity to bind the SelectionChanged event to a command:
1<ComboBox ItemsSource="{Binding Categories}"
2 SelectedItem="{Binding SelectedCategory, Mode=TwoWay}">
3 <i:Interaction.Triggers>
4 <i:EventTrigger EventName="SelectionChanged">
5 <i:InvokeCommandAction Command="{Binding CategoryChangedCommand}" />
6 </i:EventTrigger>
7 </i:Interaction.Triggers>
8</ComboBox>
1public ICommand CategoryChangedCommand => new AsyncRelayCommand(async () =>
2{
3 await LoadProductsAsync(SelectedCategory);
4});
Common Pitfalls
Using async void in the property setter: async void methods swallow exceptions silently and cannot be awaited. Use _ = LoadAsync() (fire-and-forget with a Task method that has internal try/catch) instead of making the setter async void.
Not handling rapid selection changes: If the user changes the ComboBox selection quickly, multiple async operations run simultaneously and may overwrite each other's results. Use CancellationTokenSource to cancel the previous operation when a new selection is made.
Updating ObservableCollection from a background thread: ObservableCollection raises events on the calling thread. If await resumes on a background thread, adding items throws. Use Application.Current.Dispatcher.Invoke() or ensure the synchronization context is captured.
Not disabling the ComboBox during loading: Users can change the selection while data is loading, causing race conditions. Bind IsEnabled to an inverse of IsLoading to prevent interaction during async operations.
Forgetting that SelectedItem binding fires on initial load: When the ViewModel sets the initial SelectedCategory value, the property setter fires and triggers the async operation. Guard against this with a null check or an _isInitialized flag.
Summary
Use _ = LoadAsync(value) in the property setter to fire async operations from two-way bindings
Always wrap async operations in try/catch since property setters cannot propagate exceptions
Use CancellationTokenSource to cancel previous operations on rapid selection changes
Consider CommunityToolkit.Mvvm's [ObservableProperty] and [RelayCommand] for cleaner code
Disable the ComboBox during loading and show a progress indicator for better UX