WPF(Compiled XAML)でTextChangedが初期化前に発火してnullになる原因と回避策(Loaded・データバインディング)

Compiled XAML を使った WPF で、起動直後(画面初期化中)に TextBox の TextChanged が発火し、ハンドラ内で参照したい別コントロールが null になって困ることがあります。本記事では「なぜ起こるのか」を初期化の流れから整理し、Loaded でのガードやデータバインディングでの根本回避など、実務で使える対処法を具体例つきでまとめます。

目次

どんな現象が起きているのか

典型例は次のような状況です。

  • TextBox1 に XAML で初期値を設定している、または Binding により初期表示で Text が更新される
  • TextBox1 の TextChanged を XAML でイベント接続している
  • TextChanged ハンドラ内で TextBox2 など別のコントロールを参照したい

ところが、画面が表示される前に TextChanged が走り、textBox2 がまだフィールドに代入されていないため null になって落ちます。

最小構成の再現例

実際のプロジェクトではもっと複雑でも、現象の本質はこの最小例で再現できます。

XAML の例

<Window x:Class="WpfApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Loaded="Window_Loaded">
  <StackPanel Margin="16">
    <TextBox x:Name="textBox1"
             Text="初期値"
             TextChanged="TextBox1_TextChanged" />


<TextBox x:Name="textBox2" />



 

C# の例

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }


private void TextBox1_TextChanged(object sender, TextChangedEventArgs e)
{
    // 起動直後にここへ来ることがある
    textBox2.Text = textBox1.Text; // textBox2 が null で例外になるケース
}

private void Window_Loaded(object sender, RoutedEventArgs e)
{
    // ここでは null にならない
}


}

原因:InitializeComponent 中でもプロパティ設定は進み、イベントが発火し得る

まず押さえるべき前提として、WPF のイベントは「画面が表示されてから」だけ発火するわけではありません。WPF は InitializeComponent() により XAML(実体は BAML)を読み込みながら、次のような処理を順に行います。

処理何が起きるかポイント
オブジェクト生成各コントロールのインスタンスが作られるこの時点では x:Name のフィールドがまだ設定されていないことがある
依存関係プロパティ設定XAML の属性値、Style、Binding などでプロパティが順次設定されるText を設定すると TextChanged が起きる可能性
イベント接続XAML の TextChanged="..." がハンドラに接続される接続の順序は XAML の読み込み順に依存する
名前解決(x:Name)textBox2 などのフィールドへ参照が代入されるTextChanged が先に走ると、参照先が未代入で null になる
VisualTree への接続ウィンドウに配置され、レイアウト対象になるLoaded はこの後

重要なのは、依存関係プロパティ(DependencyProperty)の変更は、ロード前でも発生するという点です。TextBox の Text は依存関係プロパティで、初期値を XAML で指定しただけでも「Text が変わった」という扱いになります。さらに Binding が絡むと、DataContext 設定のタイミング次第で初期化中に Text が更新されることもあります。

「Compiled XAML」だと特に起きやすいの?

ここでいう Compiled XAML は、WPF が XAML をビルド時に BAML へ変換し、実行時に InitializeComponent() から読み込む通常の仕組みを指すことが多いです。この仕組み自体が悪いのではなく、自動生成される接続コードの順序が、イベント発火のタイミングとぶつかったときに問題が表面化します。

実際、WPF が生成するコード(概念図)は次のような形になっています。

// 概念図:実際のコードは環境によって異なる
case 1:
    this.textBox1 = (TextBox)target;
    this.textBox1.TextChanged += this.TextBox1_TextChanged;
    return;

case 2:
this.textBox2 = (TextBox)target;
return; 

もし case 1 の直後に Text の設定が走って TextChanged が発火すると、その時点では case 2 が未実行で textBox2 が null のまま、という構図です。

なぜ「ユーザー操作していないのに TextChanged が動く」のか

TextChanged は「ユーザーが入力した」イベントではなく、「Text が変化した」イベントです。つまり次のケースでも普通に発火します。

  • XAML の Text="初期値" を読み込んだとき
  • Binding によりソース値が反映されたとき
  • コードで textBox1.Text = ... を代入したとき
  • Style/Trigger により Text が書き換えられたとき

この性質を理解していないと、「画面初期化中に TextChanged が来るのはバグでは?」と感じやすいのですが、WPF 的には自然な動作です。

回避策の全体像

対処は大きく 3 方向に分けられます。

方針狙い向いているケース注意点
ガード(Loaded/フラグ)初期化中のイベントを無視既存コードを最小変更で直したい初期表示で必要な処理は別途一回だけ実行する
イベント接続のタイミングを変える初期化完了後に TextChanged を監視開始XAML のイベント接続をやめられる「初期値変更」も拾いたいなら手動で呼ぶ必要あり
データバインディングで表現コントロール参照やイベント依存を減らすTextBox 間の同期など UI ロジックMVVM の理解が必要(ただし長期的に最も安定)

解決策:Loaded 済みかを判定してガードする

最も手堅く、既存コードへの影響が小さいのは、Loaded 前は何もしないというガードです。質問の意図にも合致し、「イベントは動くが、画面上の全コントロールが揃っていない」状況を安全にやり過ごせます。

IsLoaded を使うパターン

Window や UserControl には IsLoaded があり、VisualTree にロードされていない間は false です。

private void TextBox1_TextChanged(object sender, TextChangedEventArgs e)
{
    if (!IsLoaded)
    {
        // 画面ロード前(InitializeComponent 中や表示前)なら何もしない
        return;
    }


// ここから先は、基本的に全コントロールが生成済み
textBox2.Text = textBox1.Text;


}

ただし、起動直後に同期したい処理があるなら、ガードで落とした分を Loaded で補います。

初期同期は Window.Loaded で一回だけ行う

private void Window_Loaded(object sender, RoutedEventArgs e)
{
    // 初期表示に必要な同期をここで実施
    textBox2.Text = textBox1.Text;
}

この「ガード+Loaded で補完」は、WPF の初期化順序問題に対してかなり万能です。イベント発火の仕様を変えようとせず、扱うタイミングを明示的に遅らせる発想です。

フラグを使うパターン

画面の Loaded を基準にしたい場合は、フラグを持ってもよいです。とくに UserControl を複数回使い回す場合や、ロードの境界を明確にしたい場合に有効です。

private bool _ready;

public MainWindow()
{
InitializeComponent();
Loaded += (_, __) => _ready = true;
}

private void TextBox1_TextChanged(object sender, TextChangedEventArgs e)
{
if (!_ready) return;
textBox2.Text = textBox1.Text;
}

この方法は IsLoaded より「自分の意図した準備完了」を表現しやすい利点があります。例えば Loaded でデータ読み込みが完了してから _ready = true にする、なども可能です。

解決策:イベントは InitializeComponent 後にコードで接続する

XAML で TextChanged="..." と書くと、XAML 読み込み中にハンドラが接続され、初期化中の Text 更新を拾ってしまいます。そこで、イベント接続自体を遅らせる方法があります。

XAML からイベント接続を外す

<TextBox x:Name="textBox1"
         Text="初期値" />

コンストラクタで接続する

public MainWindow()
{
    InitializeComponent();


// この時点で x:Name のフィールドは基本的に代入済み
textBox1.TextChanged += TextBox1_TextChanged;

// 初期表示の同期が必要なら、ここで一回呼ぶ
SyncTextBoxes();


}

private void TextBox1_TextChanged(object sender, TextChangedEventArgs e)
{
SyncTextBoxes();
}

private void SyncTextBoxes()
{
if (textBox2 == null) return; // 念のための保険
textBox2.Text = textBox1.Text;
}

このやり方のポイントは、「TextChanged の仕様に頼らず、必要な同期をメソッド化して呼ぶ」ことです。初期表示の一回と変更時の両方を同じ実装にまとめられるため、保守しやすくなります。

根本解決:データバインディングで TextBox 間の同期を表現する

TextBox1 の内容を TextBox2 に反映したい、という目的が明確な場合、コードビハインドでイベントを扱うより、WPF の得意分野であるデータバインディングで表現する方が堅牢です。初期化順序やイベント発火タイミングに引っ張られにくく、拡張もしやすくなります。

ElementName バインディングで「片方向コピー」する

TextBox2 が TextBox1 の表示を追従するだけなら、ElementName バインディングがシンプルです。

<StackPanel Margin="16">
  <TextBox x:Name="textBox1"
           Text="初期値" />


この場合、TextBox1 の Text が更新されるたびに TextBox2 へ反映されます。イベントハンドラも null チェックも不要です。

MVVM で「同じプロパティを共有」する

入力値をアプリの状態として保持したい場合は、ViewModel のプロパティに両方の TextBox をバインドするのが王道です。

ViewModel の例

public class MainViewModel : INotifyPropertyChanged
{
    private string _text = "初期値";


public string Text
{
    get => _text;
    set
    {
        if (_text == value) return;
        _text = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Text)));
    }
}

public event PropertyChangedEventHandler? PropertyChanged;


}

XAML の例

<Window ...
        xmlns:local="clr-namespace:WpfApp">
  <Window.DataContext>
    <local:MainViewModel />
  </Window.DataContext>





これにより、初期値は ViewModel 側の Text から供給され、変更も同じプロパティへ集約されます。UI 同期が ViewModel を中心に回るので、「どのコントロールが先に作られたか」を気にする場面が激減します。

「どうしてもイベントが必要」なときの考え方

実務では、入力値の検証や、入力内容に応じて別コントロールを有効/無効にするといった UI ロジックで TextChanged を使うこともあります。その場合でも、次のように責務を分けると安全です。

  • 値の保持と検証:ViewModel(INotifyPropertyChanged / IDataErrorInfo / INotifyDataErrorInfo など)
  • UI 反映:XAML の Trigger や Converter、バインディング
  • どうしても必要な UI 操作:Loaded 後に限定したイベント

タイミングの選び方:Initialized / Loaded / ContentRendered の違い

「いつなら安全に他コントロールへアクセスできるか」を整理しておくと、似たトラブルを予防できます。

イベント/タイミングいつ起きるか他コントロール参照の安全性主な用途
コンストラクタ開始インスタンス生成直後低い(x:Name が未代入)フィールド初期化、軽い設定
InitializeComponent 中XAML 読み込み中低い(順序依存)XAML の自動生成処理が走る領域
Initialized初期化が完了したとき中(テンプレートや遅延生成次第)初期化完了の通知
LoadedVisualTree に追加されレイアウト対象になったとき高い(一般的に安全)初期表示の同期、UI 初期状態の確定
ContentRendered初回描画が完了した後最も高い初回描画後に実行したい重い処理、フォーカス設定

多くのケースで「Loaded を使えば解決」しますが、初回描画後でないと意味がない処理(ウィンドウサイズ確定後の計算など)は ContentRendered が向きます。

Dispatcher で「ロード後」に回す応急処置

コードの改修余地が少なく、どうしても TextChanged の中で一時的に回避したい場合は、Dispatcher でロード後に処理を投げる方法もあります。

private bool _queued;

private void TextBox1_TextChanged(object sender, TextChangedEventArgs e)
{
if (IsLoaded)
{
textBox2.Text = textBox1.Text;
return;
}


if (_queued) return;
_queued = true;

Dispatcher.BeginInvoke(new Action(() =>
{
    _queued = false;
    if (!IsLoaded) return;
    textBox2.Text = textBox1.Text;
}), DispatcherPriority.Loaded);


}

これは「初期化中に来たイベントを一旦まとめて、Loaded の優先度で再実行する」発想です。ただし、乱用すると状態が追いづらくなるため、恒久対策としては Loaded ガードやバインディング移行を推奨します。

デバッグのコツ:本当に InitializeComponent 中に走っているか確認する

原因を確信するには、次の 2 点を確認すると早いです。

  • TextChanged ハンドラにブレークポイントを置き、呼び出しスタックに InitializeComponent や Application.LoadComponent が見えるかを見る
  • 自動生成コード(obj フォルダ配下の *.g.cs など)で、x:Name の代入やイベント接続の順序を確認する

「自分が書いたロジックが悪いのか、初期化順序なのか」を切り分けると、修正方針が迷子になりません。

よくある落とし穴と対策

落とし穴:textBox2 があるはずなのに null になる

この現象は「存在しない」のではなく、「フィールドがまだ代入されていない」ことが多いです。XAML 上の順序やテンプレートの遅延生成によっても左右されます。対策は次のいずれかです。

  • Loaded まで処理しない(最優先で検討)
  • イベント接続を後ろへずらす(XAML から外してコード接続)
  • バインディングへ寄せる(そもそも参照しない)

落とし穴:初期化中に「複数回」TextChanged が走る

初期値設定、スタイル適用、バインディング更新が重なると、起動直後に複数回発火することがあります。初期化中は無視する、または Loaded で一回だけ同期することで安定します。

落とし穴:プログラム変更も拾って無限ループになる

TextBox1 の TextChanged で TextBox2 を更新し、TextBox2 の TextChanged でも TextBox1 を更新…のように双方向のイベントを組むと、更新がループすることがあります。対策は次の通りです。

  • 片方向にする(ElementName バインディングの Mode=OneWay など)
  • 更新中フラグで抑止する(_updating を使う)
  • MVVM で単一プロパティに集約する

実務で迷わないための選択ガイド

最後に、状況別のおすすめを簡単にまとめます。

やりたいことおすすめ理由
起動直後の null を止めたい(最短)TextChanged で IsLoaded ガード + Loaded で初期同期改修が小さく、初期化順序に強い
初期化中は TextChanged を拾いたくないイベントをコードで接続(InitializeComponent 後)監視開始タイミングを自分で制御できる
TextBox 間の同期をきれいにしたいElementName バインディング / MVVMイベント依存を減らし、将来の拡張に強い
初回描画後に処理したいContentRendered / DispatcherPriority.Loadedレイアウト確定後に安全に実行できる

まとめ

WPF では InitializeComponent() の最中でもプロパティ設定やバインディング更新が進み、TextChanged が発火することがあります。その時点では、XAML の読み込み順によっては他のコントロール参照がまだフィールドへ代入されておらず、null 参照が起きます。対策としては、まず Loaded を基準にガードするのが堅実で、長期的には データバインディング(ElementName / MVVM)へ寄せることで、初期化順序に依存しない設計にできます。

この記事を書いた人

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

コメント

コメントする

目次