.NET MAUI で CommunityToolkit.MVVM(ObservableObject / ObservableProperty)を使って MVVM を組んだのに、「バインドしているはずのラベルが更新されない」問題はよく起きます。原因はだいたいパターン化でき、直し方も定番があります。ハマりどころを実コードで整理します。
よくある症状:バインドしているのに表示だけが古い
たとえば XAML で次のようにバインドしているケースです。
<Label
Text="{Binding User.Name, FallbackValue='Loading...'}" />
そして Page のライフサイクル(例:OnAppearing())で非同期初期化を呼び、ユーザー情報を設定している。
protected override async void OnAppearing()
{
base.OnAppearing();
await ViewModel.InitializeAsync();
}
この状態で ViewModel 側では「値を更新した」と思っているのに、画面側が「Loading… のまま」「前回の値のまま」という現象が起きます。
結論から言うと、原因として多いのは次の2つです。
- [ObservableProperty] が生成する “プロパティ” を通して更新していない(バッキングフィールドに直接代入している)
- XAML 側のバインド名(パス)が ViewModel のプロパティ名と一致していない
さらに、ネストしたプロパティ(User.Name)やコレクション更新(同僚一覧など)が絡むと、INotifyPropertyChanged の範囲の違いで「更新しているのに反映されない」状態が作れます。
まず押さえる:ObservableProperty は “フィールド” ではなく “プロパティ” に通知を仕込む
CommunityToolkit.MVVM の [ObservableProperty] はソースジェネレーターです。つまり、あなたが書いたフィールドから、INotifyPropertyChanged を発火するプロパティを自動生成します。
たとえば、次のように書くと…
public partial class HomeViewModel : ObservableObject
{
[ObservableProperty]
private User? user;
}
ビルド時に概ね次のようなプロパティが生成されます(概念図)。
public User? User
{
get => user;
set => SetProperty(ref user, value);
}
UI に変更が伝わるのは、SetProperty(=PropertyChanged 発火)を通ったときだけです。だからこそ、次章の “最頻出の落とし穴” に直結します。
補足:生成されたコードは Visual Studio なら「ソース ジェネレーター」配下、またはプロジェクトの obj フォルダ内(ビルド構成やターゲットによってパスは変わります)で確認できます。通知が出ているか不安なら、生成コードを実際に見て「どの名前のプロパティが生成されているか」を確かめるのが近道です。
原因の最頻出:バッキングフィールドに直接代入して通知が出ていない
一番多いのがこれです。バッキングフィールド(例:user や _user)に直接代入してしまい、PropertyChanged が発火していないパターンです。
| やり方 | 例 | 結果 |
|---|---|---|
| NG:フィールドに直接代入 | user = new User { Name = "Test" }; | 通知が出ず、UI が更新されないことがある |
| OK:生成プロパティに代入 | User = new User { Name = "Test" }; | SetProperty が通り、UI が更新される |
具体例として、初期化処理で「ユーザー情報を作って入れる」コードがこうなっていたら要注意です。
// NG例:フィールドに代入している
private async Task PopulateHomePageAsync()
{
var u = await _userService.GetAsync();
user = u; // ← ここがフィールドなら通知が出ない
}
修正はシンプルで、生成されたプロパティ経由で代入します。
// OK例:生成プロパティに代入している
private async Task PopulateHomePageAsync()
{
var u = await _userService.GetAsync();
User = u; // ← SetProperty が走る
}
「でもフィールドに代入しても画面が変わったことがある」という場合は、たまたま別のプロパティ変更・画面再描画・ページ再生成などの副作用で更新されたように見えていただけ、というケースがよくあります。再現性が低い不具合は、こうした通知の漏れが原因になりがちです。
バインド名の不一致:XAML のパスと生成プロパティ名を一致させる
次に多いのが XAML 側のバインドパスのズレです。特に FallbackValue を入れていると、バインドが失敗しても「Loading…」のような文字が表示され続け、原因に気づきにくくなります。
例として、ViewModel 側ではプロパティ名が CurrentUser なのに、XAML が User.Name を見に行っているケースです。
public partial class HomeViewModel : ObservableObject
{
[ObservableProperty]
private User? currentUser;
}
この場合、XAML は CurrentUser.Name に合わせます。
<ContentPage
xmlns:vm="clr-namespace:YourApp.ViewModels"
x:DataType="vm:HomeViewModel">
<Label Text="{Binding CurrentUser.Name, TargetNullValue='Loading...'}" />
</ContentPage>
ポイントは2つです。
- プロパティ名の一致:XAML のバインドパスは “実際に存在するプロパティ名” に合わせる
- コンパイル時バインド(x:DataType)を活用:typo を早い段階で発見しやすくする
x:DataType を設定しておくと、バインドがズレた時にビルドや警告で気づけることが増えます(プロジェクト設定や記述の仕方によって検出の強さは変わります)。少なくとも、「実行しても画面が更新されない」より手前で異常に気づける可能性が上がるので、業務アプリほど推奨です。
“Loading…” が消えないときに知っておく:FallbackValue と TargetNullValue の違い
質問例では FallbackValue='Loading...' が使われています。ここが罠になりやすいので、違いを押さえておくと切り分けが速くなります。
| 指定 | 主に効く状況 | ハマりやすい例 |
|---|---|---|
FallbackValue | バインドそのものが解決できない/パスが間違いなど | プロパティ名を間違えているのに「Loading…」で気づかない |
TargetNullValue | バインドは解決できたが、値が null のとき | ユーザー情報の取得前に null を表示したい |
「初期表示では Loading…、取得できたら名前に切り替える」が目的なら、まずは TargetNullValue を使う方が意図に合うことが多いです。逆に FallbackValue は “バインド失敗” を覆い隠すため、デバッグ中は一時的に外すのも有効です。
ネストしたプロパティが更新されない理由:User.Name を後から変えるときの通知範囲
User = new User { Name = "A" } のように User オブジェクト自体を差し替えるなら、User の PropertyChanged が発火して UI は更新されやすいです。
しかし現実には次のように「User は同じインスタンスのまま、Name だけ変える」こともあります。
// よくある更新
User!.Name = "New Name";
この場合、ViewModel の User プロパティは変わっていないので、ViewModel 側からは通知が出ません。UI が追従するには、User 自体が INotifyPropertyChanged を実装して Name 変更通知を出せる必要があります。
対処パターンは大きく3つあります。
| 対処 | 向いている状況 | 注意点 |
|---|---|---|
| User を ObservableObject にする(推奨) | User が画面の複数箇所で使われ、部分更新が多い | モデル層の責務設計(DTO と分けるなど)を考える |
| Name を変えたら User を再代入(差し替え) | User が小さく、差し替えコストが低い | 参照共有していると影響が出る |
| ViewModel から OnPropertyChanged(nameof(User)) を呼ぶ | 応急処置でまず直したい | 本質的には「どこで変えたか」を追いづらくなる |
最も素直なのは User クラスも CommunityToolkit.MVVM の ObservableObject にする方法です。
public partial class User : ObservableObject
{
[ObservableProperty]
private string? name;
[ObservableProperty]
private string? email;
}
こうしておけば、User.Name を変更した時に User から通知が出て、{Binding User.Name} が更新されます。
同僚一覧などが増減しても更新されない:List ではなく ObservableCollection
ユーザー情報だけでなく、同僚一覧・通知一覧・履歴一覧など「リストを後から追加/削除」する UI でも同じ手の不具合が出ます。
List<T> は要素の増減を通知しないため、次のような更新が UI に反映されません。
// ありがちなNG:ListにAddしてもUIが追従しにくい
[ObservableProperty]
private List<User> coworkers = new();
private void AddCoworker(User u)
{
coworkers.Add(u); // コレクション変更通知がない
}
動的に増減させるなら ObservableCollection<T> を使います。
using System.Collections.ObjectModel;
[ObservableProperty]
private ObservableCollection<User> coworkers = new();
private void AddCoworker(User u)
{
Coworkers.Add(u); // UIが追従しやすい
}
「最初に一覧を丸ごと作って入れ替える」なら List でもいけますが、画面側での体験(スクロール中に増える、追加直後に見える等)を考えると、一覧系は最初から ObservableCollection を採用する方がトラブルが減ります。
OnAppearing と非同期初期化の落とし穴:更新しているのに見えない状況を作らない
OnAppearing() から InitializeAsync() を呼ぶ設計は一般的ですが、次の落とし穴があります。
- OnAppearing は複数回呼ばれる(画面に戻ってきた、タブ切り替え等)。初期化が二重に走り、値が競合する。
- 例外が握りつぶされる(async void で呼び出す、try/catch がない)。結果としてデータが入らず “更新されない” ように見える。
- バックグラウンドスレッドで更新(ConfigureAwait(false) などの影響)し、UI 反映が不安定になる。
実務では、次のような “初回だけ初期化” のガードを入れることが多いです。
private bool _initialized;
protected override async void OnAppearing()
{
base.OnAppearing();
if (_initialized) return;
_initialized = true;
try
{
await ViewModel.InitializeAsync();
}
catch (Exception ex)
{
// ログ出力やエラーメッセージ表示など
}
}
また、データ取得が長い場合はキャンセル(CancellationToken)も検討対象です。画面遷移直後に結果が返ってきて古い画面を更新しようとすると、見た目として「更新されない」「一瞬だけ変な値になる」などの不具合に繋がります。
UI スレッドの更新が怪しいときは、最終手段として明示的にメインスレッドへ戻すと切り分けができます。
using Microsoft.Maui.ApplicationModel;
private async Task PopulateHomePageAsync()
{
var u = await _userService.GetAsync();
MainThread.BeginInvokeOnMainThread(() =>
{
User = u;
});
}
常にこれが必要とは限りませんが、スレッドが絡むと原因究明が難しくなるため、まずは「メインスレッドで確実にプロパティを更新できているか」を確認する価値があります。
実例:よくある NG → OK の書き換えパターン集
| シーン | NG例 | OK例 |
|---|---|---|
| ユーザー取得後の代入 | user = fetchedUser; | User = fetchedUser; |
| XAML のパス | {Binding User.Name}(VMはCurrentUser) | {Binding CurrentUser.Name} |
| ネスト更新 | User.Name = "x";(Userが通知しない) | User を ObservableObject にする/差し替える |
| 一覧の追加 | List<T>.Add | ObservableCollection<T>.Add |
すぐ使える最小サンプル:HomePage + ViewModel + Model
「これで更新される」という基準点になる最小構成です。まずこの形で動かし、そこから自分の構成に寄せていくと、どこで通知が途切れたか発見しやすくなります。
XAML(Compiled Binding を使う例)
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:YourApp.ViewModels"
x:Class="YourApp.Views.HomePage"
x:DataType="vm:HomeViewModel">
<VerticalStackLayout Padding="16" Spacing="12">
<Label
FontSize="24"
Text="{Binding CurrentUser.Name, TargetNullValue='Loading...'}" />
<Label
Text="{Binding CurrentUser.Email, TargetNullValue='(no email)'}" />
<Button
Text="名前を変更する"
Command="{Binding ChangeNameCommand}" />
<Label Text="同僚" FontAttributes="Bold" />
<CollectionView ItemsSource="{Binding Coworkers}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="vm:User">
<Label Text="{Binding Name}" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
ViewModel(ObservableProperty は “プロパティで更新” を徹底)
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
namespace YourApp.ViewModels;
public partial class HomeViewModel : ObservableObject
{
[ObservableProperty]
private User? currentUser;
[ObservableProperty]
private ObservableCollection<User> coworkers = new();
private bool _initialized;
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
// 例:APIやDBから取得した想定
await Task.Delay(300);
// 重要:フィールドではなく生成プロパティに代入する
CurrentUser = new User
{
Name = "Test Arnab",
Email = "[email protected]"
};
Coworkers = new ObservableCollection<User>
{
new User { Name = "Aki", Email = "[email protected]" },
new User { Name = "Ken", Email = "[email protected]" }
};
}
[RelayCommand]
private void ChangeName()
{
// ネスト更新が UI に反映されるように、User 自体も ObservableObject にしている
if (CurrentUser is null) return;
CurrentUser.Name = $"{CurrentUser.Name} (updated)";
}
}
Model(ネスト更新の通知が必要なら ObservableObject 化)
using CommunityToolkit.Mvvm.ComponentModel;
namespace YourApp.ViewModels;
// 例では簡略化のため ViewModels 名前空間に置いているが、実際は Models に分けてもOK
public partial class User : ObservableObject
{
[ObservableProperty]
private string? name;
[ObservableProperty]
private string? email;
}
この構成なら、次が成立します。
CurrentUserの差し替えで画面が更新される(ViewModel が通知)CurrentUser.Nameの変更で画面が更新される(User が通知)- 同僚の追加/削除が画面に反映される(ObservableCollection が通知)
デバッグ手順:画面が更新されない時に最短で原因へ辿り着く
「更新されない」は原因が複数あるため、闇雲に直すと時間が溶けます。次の順番で潰すのが効率的です。
| 確認ポイント | 見るべきもの | 典型的な原因 |
|---|---|---|
| BindingContext が想定通りか | ページ生成時の設定箇所 | BindingContext 未設定/別VMが入っている |
| プロパティ名が一致しているか | XAML の Binding パス | User と CurrentUser の取り違え、スペルミス |
| 更新はプロパティ経由か | 代入箇所(フィールド or プロパティ) | バッキングフィールドへ直接代入 |
| ネスト更新の通知が出るか | User クラスの実装 | User が INotifyPropertyChanged ではない |
| 一覧の増減が通知されるか | コレクション型 | List を使っている |
| 例外で初期化が止まっていないか | ログ、try/catch | InitializeAsync 内で例外→値が入らない |
特に FallbackValue を入れていると「バインド名が違う」問題が見えにくくなるため、切り分け中は一度外す、または TargetNullValue に寄せるのが実務的です。
まとめ:更新されない問題は “通知の通り道” と “名前の一致” でほぼ解決できる
- [ObservableProperty] のバッキングフィールドに直接代入しない。必ず生成プロパティに代入して通知を出す。
- XAML のバインド名(パス)を ViewModel のプロパティ名と一致させる。Compiled Binding(x:DataType)でズレを早期発見。
- ネストしたプロパティ(User.Name)を後から更新するなら、User 側も通知を出せる設計にする。
- 増減する一覧は ObservableCollection を使い、追加/削除を UI に反映させる。
「バインドしているのに画面が更新されない」は、最終的には “通知が出ていない” か “見に行く先が間違っている” のどちらかです。今回のチェックリストとサンプルを基準に、どこで通り道が途切れているかを一つずつ確認していけば、ほぼ確実に解消できます。

コメント