WPF MVVMでItemsSourceに非同期データをバインドする方法|ObservableCollectionとAsyncRelayCommandで安全にロード

WPFのMVVMで非同期に一覧データを取得したいとき、Task<List<Person>>をそのままItemsSourceへバインドしたくなる場面があります。しかしItemsSourceが受け取れるのはコレクションであり、Taskはそのまま表示できません。本記事では理由と、実務で崩れにくい非同期ロードの実装を具体例付きで解説します。

目次

結論:Task<List<Person>>はItemsSourceに直接バインドできない

WPFのItemsControl(ListBox、ListView、DataGridなど)のItemsSourceは、基本的にIEnumerable(列挙可能なコレクション)を期待します。ところがTask<T>は「将来Tが手に入る」ことを表す非同期処理であり、列挙可能なコレクションではありません。

そのため、次のようにTask型プロパティを公開しても、WPFは中身のList<Person>を自動で待ってくれません。

public Task<List<Person>> PersonsTask => GetPersonsAsync();

さらに厄介なのは、データバインディングは「必要に応じてプロパティを何度も評価する」ことがある点です。上のようなゲッター実装だと、画面の更新や再評価のタイミングでGetPersonsAsync()が何度も呼ばれ、意図せず複数回ロードが走る可能性があります(DB/APIに余計な負荷がかかる、順不同で結果が入れ替わる、など)。

まず押さえる:WPFのバインディングはTaskをawaitしない

「XAMLでawaitできればいいのに」と思うのは自然ですが、WPFの標準バインディングはTaskの完了を待ちません。よく混同されるのがBinding.IsAsyncです。

  • Binding.IsAsync:値の取得自体を別スレッドで行い、UIのブロックを避けるための仕組み
  • Task/await:非同期処理の完了を待って結果を得るための言語機能

IsAsyncはTaskを自動的にawaitしてくれるわけではないため、ItemsSourceにTaskを渡しても期待通りには動きません。

「Taskをそのままバインドしたい」気持ちの背景

MVVMでは「Viewはデータにバインドするだけ」「ViewModelが状態と処理を持つ」という整理がしやすいので、非同期取得も「プロパティとして公開してXAML側で完結させたい」と考えがちです。特に、次のような要求があるとTaskバインドに惹かれます。

  • 画面表示と同時に自動で読み込みたい
  • 読み込み中・完了・失敗をXAMLだけで表現したい
  • コードビハインドを極力ゼロにしたい

しかしWPF標準の範囲では、Taskを直接ItemsSourceに流し込む構造は無理が出やすいため、ViewModel側で「結果コレクション」と「ロード状態」を分けて持つのが安定します。

回避策:IValueConverterでTask→Listに変換は“形としては”可能だが危険

コンバーターでTaskをList<Person>に変換してItemsSourceへ流す、という発想はよく出ます。例えば以下のようなコードです。

public sealed class TaskToResultConverter : IValueConverter
{
    public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is Task<List<Person>> task)
        {
            // ❌ 非推奨:UIスレッドをブロックしやすい / デッドロックの原因になり得る
            return task.Result;
        }
        return null;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => Binding.DoNothing;
}

この方法が危険な理由は次の通りです。

  • UIが固まる:非同期のはずが、.Resultで同期的に待つため、完了まで画面が止まります
  • デッドロックの可能性:タスクがUIコンテキストに戻ろうとしているのにUIが.Resultで待っていると詰まります
  • 例外処理が辛い:Taskの例外がAggregateExceptionで表面化しやすく、表示やログ設計が崩れがちです
  • コンバーターはasyncにできない:本当にやりたい「awaitして返す」が標準のIValueConverterではできません

結果として「一見動くが、遅い環境や本番負荷で壊れる」形になりやすく、長期運用には向きません。

推奨パターン:ObservableCollectionを公開し、非同期結果を投入する

WPF MVVMで最も扱いやすいのは、ItemsSourceには常にObservableCollection<Person>をバインドし、ロードの完了後に中身を更新する方式です。

方式ItemsSourceに渡すもの更新通知実務での安定度
Task直バインドTask<List<Person>>そもそも表示できない低い
List差し替えList<Person>PropertyChangedが必要中(シンプルな画面なら可)
ObservableCollection投入ObservableCollection<Person>CollectionChangedで自動反映高い(定番)

この方式のメリットは、XAMLは常に同じコレクション(参照)にバインドし続けられるため、UI側の差し替えコストやバインディングの揺れが小さいことです。さらに、追加・削除が自動反映されるので、ページングや部分更新にも発展させやすくなります。

サンプル:Personモデル

public sealed class Person
{
    public int Id { get; init; }
    public string Name { get; init; } = "";
    public int Age { get; init; }
}

サンプル:サービス層(非同期取得)

データ取得はViewModelに直書きせず、サービス(リポジトリ)に切り出すとテストもしやすく、MVVMらしく整理できます。

public interface IPersonService
{
    Task<IReadOnlyList<Person>> GetPersonsAsync(CancellationToken cancellationToken);
}

サンプル:ViewModel(ObservableCollectionへ反映)

以下は「読み込み」「キャンセル」「エラー表示」「読み込み中フラグ」まで含めた、実務でよく使う形です。コミュニティで定番のCommunityToolkit.Mvvmを例にします(自前でINotifyPropertyChanged実装でも考え方は同じです)。

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
using System.Threading;

public partial class PersonsViewModel : ObservableObject
{
    private readonly IPersonService _service;
    private CancellationTokenSource? _cts;

    public ObservableCollection<Person> Persons { get; } = new();

    [ObservableProperty]
    private bool isLoading;

    [ObservableProperty]
    private string? errorMessage;

    public IAsyncRelayCommand LoadCommand { get; }
    public IRelayCommand CancelCommand { get; }

    public PersonsViewModel(IPersonService service)
    {
        _service = service;

        LoadCommand = new AsyncRelayCommand(LoadAsync);
        CancelCommand = new RelayCommand(Cancel, () => IsLoading);
    }

    partial void OnIsLoadingChanged(bool value)
    {
        CancelCommand.NotifyCanExecuteChanged();
        LoadCommand.NotifyCanExecuteChanged();
    }

    private async Task LoadAsync()
    {
        ErrorMessage = null;

        // 連続実行を防ぎたい場合は、ここでIsLoadingを見てreturnするなどもあり
        IsLoading = true;

        _cts?.Cancel();
        _cts = new CancellationTokenSource();

        try
        {
            var list = await _service.GetPersonsAsync(_cts.Token);

            Persons.Clear();
            foreach (var p in list)
            {
                Persons.Add(p);
            }
        }
        catch (OperationCanceledException)
        {
            // キャンセルはエラー扱いにしないのが一般的
        }
        catch (Exception ex)
        {
            // 実務ではログ基盤に送る/ユーザー向け文言に変換する
            ErrorMessage = ex.Message;
        }
        finally
        {
            IsLoading = false;
        }
    }

    private void Cancel()
    {
        _cts?.Cancel();
    }
}

サンプル:XAML(ItemsSourceはPersonsにバインド)

XAML側はシンプルに「表示対象は常にPersons」とし、読み込みはコマンドで行います。

<Window x:Class="Sample.PersonsWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Persons" Height="420" Width="640">

    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BoolToVis" />
    </Window.Resources>

    <Grid Margin="12">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <StackPanel Orientation="Horizontal" Margin="0,0,0,8">
            <Button Content="読み込み" Command="{Binding LoadCommand}" />
            <Button Content="キャンセル" Command="{Binding CancelCommand}" Margin="8,0,0,0" />

            <TextBlock Text="{Binding ErrorMessage}"
                       Margin="12,0,0,0"
                       VerticalAlignment="Center"
                       Foreground="Red" />
        </StackPanel>

        <ListView Grid.Row="1" ItemsSource="{Binding Persons}">
            <ListView.View>
                <GridView>
                    <GridViewColumn Header="ID" DisplayMemberBinding="{Binding Id}" Width="80" />
                    <GridViewColumn Header="氏名" DisplayMemberBinding="{Binding Name}" Width="240" />
                    <GridViewColumn Header="年齢" DisplayMemberBinding="{Binding Age}" Width="80" />
                </GridView>
            </ListView.View>
        </ListView>

        <ProgressBar Grid.Row="2"
                     Height="6"
                     IsIndeterminate="True"
                     Margin="0,8,0,0"
                     Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}" />
    </Grid>
</Window>

この形にしておくと、ItemsSourceには常にコレクションが入るため、非同期のタイミングに左右されず安定して表示できます。また読み込み状態(IsLoading)を別プロパティにしているので、プログレス表示やボタンの有効/無効制御も自然に書けます。

Listで差し替えるだけでも動くが、更新戦略に注意

「一覧は毎回丸ごと差し替えで十分」「追加・削除はしない」という画面なら、ObservableCollectionではなくList<Person>を差し替える形でも問題ありません。ただしその場合は、プロパティ自体を差し替え、必ずPropertyChangedを通知する必要があります。

private List<Person> _persons = new();
public List<Person> Persons
{
    get => _persons;
    private set => SetProperty(ref _persons, value);
}

private async Task LoadAsync()
{
    var list = await _service.GetPersonsAsync(CancellationToken.None);
    Persons = list.ToList(); // 参照を差し替える
}

ただしList差し替えは次のような場面で拡張しにくいことがあります。

  • ロード途中で1件ずつ追加して進捗を見せたい
  • 差分更新(変更のあった要素だけ差し替え)をしたい
  • フィルタ・ソートをかけたビューを維持したい

将来的に機能が増えそうなら、最初からObservableCollectionにしておくほうが手戻りが少ないです。

MVVMらしくする:Loadedイベントに頼りすぎないロードの起点

「画面が表示されたら自動でロードしたい」場合、ViewのLoadedイベントからViewModelのメソッドを呼ぶ例を見かけます。これは短期的には簡単ですが、画面遷移や再表示で複数回呼ばれる・テストしにくい、といった問題が起きがちです。MVVMで整理するなら、ロードもコマンドとして公開し、呼び出し契機だけView側で用意するのが安全です。

起点メリット注意点
ボタン(明示的)動作が分かりやすい/二重ロードしにくい初期表示で空になるのでUXは工夫が必要
画面表示時に自動(Loaded等)ユーザー操作なしでデータが出る再表示で複数回ロードになりやすい
ナビゲーション(遷移時)「画面に入る」を契機にできるフレームワーク依存(Prism等)

自動ロードしたい場合の現実的な落としどころ

フレームワークを使っていない場合は「ViewのコンストラクタでDataContextを設定した直後にLoadCommand.Execute(null)」のような最小限のコードビハインドを許容するのが、結果として一番トラブルが少ないことも多いです。重要なのは、ロード処理そのものはViewModelに閉じ、Viewは「呼ぶだけ」にすることです。

落とし穴集:非同期ロードが不安定になる典型パターンと対策

Taskを直接バインドできない問題を回避しても、非同期ロードは実務でつまずきやすいポイントがいくつもあります。ここを先に押さえておくと、後からの不具合調査が激減します。

よくある問題症状対策
Task.Result / Wait() を使う画面が固まる/時々デッドロック必ずawaitで待つ。UIスレッドをブロックしない
プロパティゲッターでGetPersonsAsync()を呼ぶ複数回ロード/結果が入れ替わるコマンドや明示的なメソッドで一度だけ呼ぶ
例外を握りつぶす空表示のまま原因不明ErrorMessage/ログ出力を用意し、ユーザーにフィードバック
キャンセルがない画面遷移しても裏で通信が続くCancellationTokenを流し、画面破棄時にCancel
ConfigureAwait(false)の扱いミスコレクション更新で例外(別スレッド)UI更新はUIスレッドで。サービス層のみConfigureAwait(false)を検討

UIスレッドとDispatcherの考え方

WPFのObservableCollectionは、基本的にUIスレッドから更新する前提です。通常、ViewModelのawaitはUIコンテキストに戻るため、次のような形ならDispatcher不要で更新できます。

var list = await _service.GetPersonsAsync(ct); // 通常ここでUIに戻る
Persons.Clear();
foreach (var p in list) Persons.Add(p);

一方で、サービス層やライブラリ側でConfigureAwait(false)を多用していたり、明示的にバックグラウンドスレッドで処理している場合は、UIスレッドに戻らないことがあります。そのときはViewModel側でDispatcherを使って更新します。

var list = await _service.GetPersonsAsync(ct).ConfigureAwait(false);

// ここがUIスレッドでない可能性があるのでDispatcherへ
await Application.Current.Dispatcher.InvokeAsync(() =>
{
    Persons.Clear();
    foreach (var p in list) Persons.Add(p);
});

ポイントは「UI更新はUIスレッド」という原則を守ることです。逆に言えば、データ取得(I/O)部分はUIと切り離して最適化しやすくなります。

読み込み中表示を“それっぽく”する:UXを上げる小技

非同期ロードの実装は「動く」だけでなく、「待たされている感」を減らす工夫が効きます。以下はWPFで手堅いパターンです。

  • 読み込み中はボタンを無効化する(AsyncRelayCommandは標準でやりやすい)
  • 空データ時は「データがありません」を表示する
  • エラー時は一覧ではなくメッセージを優先して表示する

例えば、ItemsControlの上に読み込み中オーバーレイを重ねると、画面が切り替わらずユーザーが安心します。

<Grid>
    <ListView ItemsSource="{Binding Persons}" />

    <Border Background="#80FFFFFF"
            Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
        <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
            <ProgressBar IsIndeterminate="True" Width="240" Height="6" />
            <TextBlock Text="読み込み中..." Margin="0,8,0,0" HorizontalAlignment="Center" />
        </StackPanel>
    </Border>
</Grid>

こうした表示は見た目の問題に見えますが、実際は「連打」「二重ロード」「閉じたのに裏で動いている」などの二次被害も抑えやすくなります。

大量データの場合:仮想化と段階的ロードもセットで考える

Person一覧が数千〜数万件になる可能性があるなら、非同期ロードと同時にUI側の負荷も意識しましょう。代表的には以下です。

  • UI仮想化:画面に見えている行だけ生成する
  • 段階的ロード:最初は先頭だけ、スクロールやページングで追加取得する

例えばListViewでは、仮想化が無効になる条件(ScrollViewer.CanContentScrollやレイアウトの組み方など)があるため、意図せず重くなることがあります。まずは仮想化が有効になっているか確認し、必要に応じて設定します。

<ListView ItemsSource="{Binding Persons}"
          VirtualizingPanel.IsVirtualizing="True"
          VirtualizingPanel.VirtualizationMode="Recycling"
          ScrollViewer.CanContentScroll="True" />

段階的ロードは設計が一段上がりますが、ObservableCollection方式なら「追加で取得した分をAddする」だけでUIに自然に反映できるため、Task直バインドより発展させやすいのが利点です。

どうしてもTaskを“状態込みで”バインドしたい場合の現実解

「読み込み中かどうか」「例外が出たか」「結果が揃ったか」をXAMLで扱いたい場合、TaskそのものをItemsSourceに流すのではなく、Taskを監視するラッパーを用意し、Resultや状態プロパティをバインドする方法があります。

これは“Taskを直接バインド”ではありませんが、「Taskの完了を待ってResultを通知する」点で要求に近く、コンバーターより安全です。

Task監視ラッパー例(NotifyTaskCompletion風)

using System.ComponentModel;

public sealed class TaskNotifier : INotifyPropertyChanged
{
public Task Task { get; }
public bool IsCompleted => Task.IsCompleted;
public bool IsSuccessfullyCompleted => Task.Status == TaskStatus.RanToCompletion;
public bool IsFaulted => Task.IsFaulted;
public Exception? Exception => Task.Exception;


public T? Result => IsSuccessfullyCompleted ? Task.Result : default;

public event PropertyChangedEventHandler? PropertyChanged;

public TaskNotifier(Task<T> task)
{
    Task = task;
    _ = WatchAsync();
}

private async Task WatchAsync()
{
    try { await Task.ConfigureAwait(false); }
    catch { /* 例外は状態プロパティで扱う */ }

    OnPropertyChanged(nameof(IsCompleted));
    OnPropertyChanged(nameof(IsSuccessfullyCompleted));
    OnPropertyChanged(nameof(IsFaulted));
    OnPropertyChanged(nameof(Exception));
    OnPropertyChanged(nameof(Result));
}

private void OnPropertyChanged(string name)
    => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));


}

ViewModel側:TaskNotifierのResultをItemsSourceへ

public TaskNotifier<IReadOnlyList<Person>> PersonsTask { get; }

public PersonsViewModel(IPersonService service)
{
PersonsTask = new TaskNotifier>(
service.GetPersonsAsync(CancellationToken.None));
}
<ListView ItemsSource="{Binding PersonsTask.Result}" />

この方法の注意点は、結果が到着した後にResultの変更通知が出る設計になっているか、そして再読み込み時にTaskNotifier自体を差し替えるならTaskNotifierプロパティにも通知が必要、という点です。シンプルな一覧ならObservableCollection方式のほうが実装が読みやすく、デバッグもしやすいことが多いです。

よくある質問と判断基準

Taskプロパティを公開するのは完全にダメ?

ダメではありません。例えば「ロード状態を表すためにTaskを保持する」「画面遷移時にキャンセルしたいので現在のTaskを追跡する」など、ViewModel内部の管理用途としては有効です。ただし、ItemsSourceに直接渡して表示させようとすると設計が歪みやすい、というのがポイントです。

GetPersonsAsync()をプロパティのゲッターで呼ぶのが危ないのはなぜ?

WPFのバインディングは、再描画・レイアウト・トリガーなどの都合でプロパティを複数回読むことがあります。ゲッターが副作用(通信、DBアクセス)を持つと、読み取りのたびに処理が走ってしまいます。非同期かどうかに関係なく、プロパティのゲッターは副作用なしを基本にすると安全です。

DataGridでも同じ?

同じです。DataGrid.ItemsSourceも列挙可能なコレクションを期待するので、Taskを直接渡すのは不可です。ObservableCollectionやListをバインドし、非同期取得後に中身(または参照)を更新する設計にします。

最小実装はどれ?

まず動かすだけなら、次の組み合わせが最小で安定します。

  • ViewModelにObservableCollection<Person>を1つ用意
  • LoadAsyncでawaitしてからClear→Add
  • XAMLはItemsSource="{Binding Persons}"だけ

そこにIsLoadingとErrorMessageを足すと、実務でも困りにくい完成度になります。

まとめ:ItemsSourceは「結果のコレクション」に、Taskは「取得処理」に分離する

WPF MVVMで非同期データ読み込みを扱うコツは、UIが欲しいのはTaskではなく“表示できるコレクション”だと割り切ることです。Task<List<Person>>を直接ItemsSourceへバインドするのではなく、

  • ItemsSourceにはObservableCollection<Person>(またはList)をバインド
  • 読み込みはawaitで実行し、完了後にコレクションへ反映
  • 読み込み中・エラー・キャンセルなど、状態を別プロパティで管理

という分離を徹底すると、UIが固まらず、再評価で二重ロードも起きにくい、保守しやすいMVVMになります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次