公開日 2026-06-21

FlutterでBuildContextとKeyを理解する

Flutter の BuildContext と Key を最小例で整理し、Widget ツリー上の位置と並び替え時の state 対応付けを判断できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に BuildContext と Key の役割を切り分ける
  6. 3. BuildContext は「今いる場所」を表す
  7. コードのポイント
  8. 4. await のあとに context を使うときは context.mounted を先に見る
  9. コードのポイント
  10. 5. Key は「どの Widget がどのデータか」を区別する
  11. コードのポイント
  12. 6. Key の選び方を最小限に絞る
  13. 7. まとめ

FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために) の次に止まりやすいのが、BuildContextKey です。BuildContextKey で迷いやすいのは、名前よりも役割の違いです。context が何を指しているのか曖昧なまま Scaffold.of(context)Navigator.of(context) を呼んだり、一覧の並び替えで state が思った位置についてこなかったりする点にあります。この記事では BuildContext を「Widget ツリー上の位置」、Key を「Widget の同一性」として切り分け、最初にぶつかりやすい場面だけを最小例で整理します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築と Dart 基礎は終わった人
  • StatefulWidget を少し書き始めたが、BuildContextKey の説明が抽象的で実装へ結びつきにくい人
  • レイアウト、カスタム Widget、ルーティングへ進む前に、Widget ツリーの基本的な見方を固めたい人

この記事で到達する状態

  • BuildContext を「今いる場所」として説明できる
  • Builder や子 Widget 切り出しで、必要な位置の context を取り直せる
  • await のあとに context.mounted を確認する意味が分かる
  • 一覧の並び替えで ValueKey が必要になる理由を説明できる
  • ValueKey / ObjectKey / UniqueKey / GlobalKey の違いを大まかに選び分けられる

非対象

  • Element / RenderObject の内部実装
  • InheritedWidget の自作
  • Riverpod / Bloc などの状態管理ライブラリ
  • GlobalKey を使った高度な制御
  • パフォーマンス最適化の深掘り

今回は Flutter 内部の実装を追いません。BuildContextKey がどの場面で何を見分けるための仕組みかを把握します。ここが整理できると、後続のレイアウト、ルーティング、一覧 UI でも迷いが減ります。

2. 先に BuildContext と Key の役割を切り分ける

まずは 2 つの役割を分けます。

概念役割最初に出会う場面見るべき点
BuildContextWidget ツリー上の位置Theme.of(context) Navigator.of(context) Scaffold.of(context)その context から親方向に何が見えるか
KeyWidget の同一性を区別する手掛かりリスト並び替え、差し替え、フォーム、アニメーションどの子 Widget がどのデータに対応しているか

この 2 つは目的が違います。BuildContext は「どこから探すか」、Key は「どの子を同一とみなすか」です。

flowchart TD
  A[Widget tree] --> B[BuildContext]
  A --> C[Key]
  B --> D[Theme.of context]
  B --> E[Navigator.of context]
  B --> F[Scaffold.of context]
  C --> G[並び替え時の state 対応付け]
  C --> H[どの子 Widget がどのデータかを判定]

以降のコード例はすべて lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要のため、DartPad でも試せます。

3. BuildContext は「今いる場所」を表す

BuildContext が抽象的に見える原因は、「画面全体で 1 つだけ持っている情報」のように感じやすい点にあります。実際には、context は Widget ツリー上の位置です。同じ画面の中でも、どの位置の context を使うかで見える親 Widget が変わります。

最初に詰まりやすいのは Scaffold.of(context) です。StatelessWidgetStatefulWidgetbuild 引数でもらう context は、その Widget 自身の位置を指します。build の中でこれから返す Scaffold より上にあるため、そこで Scaffold.of(context) を呼んでも、その Scaffold にはまだ届かない点に注意が必要です。

lib/main.dart は次の内容で作成します。

このファイルは、BuildContext が Widget ツリー上のどこを指しているかを Drawer 操作で確かめるサンプルです。注目点は、同じ画面でも pageContextinnerContext では見える親 Widget が違うことです。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BuildContext sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      home: const ContextPositionPage(),
    );
  }
}

class ContextPositionPage extends StatelessWidget {
  const ContextPositionPage({super.key});

  void _tryOpenDrawerWithPageContext(BuildContext pageContext) {
    final scaffoldState = Scaffold.maybeOf(pageContext);
    final messenger = ScaffoldMessenger.maybeOf(pageContext);

    final message = scaffoldState == null
        ? 'pageContext は Scaffold より上にあるため、Drawer を開けません。'
        : 'pageContext から Drawer を開けました。';

    messenger?.showSnackBar(
      SnackBar(content: Text(message)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('BuildContext の位置を見る')),
      drawer: Drawer(
        child: SafeArea(
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: const [
              Text(
                'Drawer が開いたら、Scaffold の内側の context を使えています。',
                style: TextStyle(fontSize: 16),
              ),
            ],
          ),
        ),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Card(
              child: Padding(
                padding: EdgeInsets.all(16),
                child: Text(
                  'build の引数で受け取る pageContext は、この画面を返す Scaffold より上の位置にあります。',
                ),
              ),
            ),
            const SizedBox(height: 16),
            FilledButton(
              onPressed: () => _tryOpenDrawerWithPageContext(context),
              child: const Text('pageContext で Drawer を開こうとする'),
            ),
            const SizedBox(height: 12),
            Builder(
              builder: (innerContext) {
                final theme = Theme.of(innerContext);

                return Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    OutlinedButton.icon(
                      onPressed: () => Scaffold.of(innerContext).openDrawer(),
                      icon: const Icon(Icons.menu),
                      label: const Text('Builder の context で Drawer を開く'),
                    ),
                    const SizedBox(height: 16),
                    Container(
                      padding: const EdgeInsets.all(16),
                      decoration: BoxDecoration(
                        color: theme.colorScheme.primaryContainer,
                        borderRadius: BorderRadius.circular(16),
                      ),
                      child: Text(
                        'innerContext は Scaffold の内側にあるため、Scaffold.of(context) に届きます。',
                        style: TextStyle(
                          color: theme.colorScheme.onPrimaryContainer,
                        ),
                      ),
                    ),
                  ],
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

コードのポイント

pageContextScaffold より上にある

  void _tryOpenDrawerWithPageContext(BuildContext pageContext) {
    final scaffoldState = Scaffold.maybeOf(pageContext);
    final messenger = ScaffoldMessenger.maybeOf(pageContext);

    final message = scaffoldState == null
        ? 'pageContext は Scaffold より上にあるため、Drawer を開けません。'
        : 'pageContext から Drawer を開けました。';

Scaffold.maybeOf(pageContext) を使っているのは、対象が見つからないとき null を返して画面上で結果を説明するためです。ここで見ているのは API の違いではなく、pageContextScaffold の外側にあるという位置関係です。

BuilderinnerContext で初めて Drawer に届く

            Builder(
              builder: (innerContext) {
                final theme = Theme.of(innerContext);

                return Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    OutlinedButton.icon(
                      onPressed: () => Scaffold.of(innerContext).openDrawer(),

Builder を 1 枚挟むことで、innerContextScaffold の内側に置かれます。BuildContext を「画面全体の共通参照」と見るとこの差は見えませんが、位置として捉えると、同じ画面でも見える親方向の範囲が違うことが整理しやすくなります。

BuildContextの位置確認(1) BuildContextの位置確認(2)

4. await のあとに context を使うときは context.mounted を先に見る

前の記事では State.mounted を見ましたが、BuildContext 側にも同じ発想があります。非同期処理のあとで Navigator.of(context)ScaffoldMessenger.of(context) を呼ぶなら、先にその context がまだ有効かを確認します。

lib/main.dart の内容:

このファイルは、非同期処理の完了前に画面が閉じられるケースを再現するサンプルです。注目点は、await のあとで context.mounted を確認し、無効になった context を触らないようにしていることです。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'context.mounted sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('context.mounted の確認')),
      body: Center(
        child: FilledButton(
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute<void>(
                builder: (_) => const SavePage(),
              ),
            );
          },
          child: const Text('保存画面を開く'),
        ),
      ),
    );
  }
}

class SavePage extends StatefulWidget {
  const SavePage({super.key});

  @override
  State<SavePage> createState() => _SavePageState();
}

class _SavePageState extends State<SavePage> {
  bool _saving = false;

  Future<void> _save(BuildContext context) async {
    if (_saving) {
      return;
    }

    setState(() {
      _saving = true;
    });

    await Future<void>.delayed(const Duration(seconds: 2));

    if (!context.mounted) {
      return;
    }

    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('保存が完了しました。')),
    );
    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('保存画面')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text(
              '保存には 2 秒かかる想定です。待っている間に戻るボタンで画面を閉じると、この page の context は無効になります。',
            ),
            const SizedBox(height: 16),
            FilledButton(
              onPressed: _saving ? null : () => _save(context),
              child: Text(_saving ? '保存中...' : '保存して戻る'),
            ),
            const SizedBox(height: 12),
            OutlinedButton(
              onPressed: () => Navigator.of(context).pop(),
              child: const Text('待たずに閉じる'),
            ),
          ],
        ),
      ),
    );
  }
}

コードのポイント

await のあとで context.mounted を確認する

  Future<void> _save(BuildContext context) async {
    if (_saving) {
      return;
    }

    setState(() {
      _saving = true;
    });

    await Future<void>.delayed(const Duration(seconds: 2));

    if (!context.mounted) {
      return;
    }

保存ボタンを押したあとに 2 秒待つ間、利用者は戻るボタンで SavePage を閉じられます。そのため await をまたいだ直後で context.mounted を確認し、無効になった context で次の UI 操作へ進まないようにします。

mounted 確認のあとに UI を更新する

    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('保存が完了しました。')),
    );
    Navigator.of(context).pop();

SnackBar 表示と画面を閉じる処理は、どちらも有効な context を前提にしています。BuildContext を値の受け渡しだけに見ているとこの確認は抜けやすいですが、位置情報であり画面の寿命と結び付いていると理解すると、非同期処理のあとに何を確認すべきかが見えやすくなります。

context.mountedの確認(1) context.mountedの確認(2)

5. Key は「どの Widget がどのデータか」を区別する

次は Key です。Key が必要になる最初の場面は、子 Widget を並び替えたり差し替えたりするときです。Flutter は毎回 Widget を丸ごと保存しているわけではなく、前回の子と今回の子を対応付けながら state を再利用します。この対応付けで、位置だけでは足りないときに Key を使います。

lib/main.dart の内容:

このファイルは、並び替え時に child の state がどのデータへ紐付くかを ValueKey の有無で比べるサンプルです。注目点は、Key が state を保存する仕組みそのものではなく、前回と今回の child を対応付ける手掛かりだという点です。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Key sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
      ),
      home: const KeyDemoPage(),
    );
  }
}

class Worker {
  const Worker({
    required this.id,
    required this.name,
  });

  final String id;
  final String name;
}

class KeyDemoPage extends StatefulWidget {
  const KeyDemoPage({super.key});

  @override
  State<KeyDemoPage> createState() => _KeyDemoPageState();
}

class _KeyDemoPageState extends State<KeyDemoPage> {
  bool _useKeys = false;
  List<Worker> _workers = const [
    Worker(id: 'A', name: '棚A'),
    Worker(id: 'B', name: '棚B'),
  ];

  void _swapWorkers() {
    setState(() {
      _workers = _workers.reversed.toList();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Key で state の対応付けを見る')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          SwitchListTile(
            title: const Text('ValueKey を付ける'),
            subtitle: const Text('オンにするとチェック状態がデータ側へ追従します。'),
            value: _useKeys,
            onChanged: (value) {
              setState(() {
                _useKeys = value;
              });
            },
          ),
          const SizedBox(height: 12),
          FilledButton(
            onPressed: _swapWorkers,
            child: const Text('カードの順番を入れ替える'),
          ),
          const SizedBox(height: 12),
          const Text(
            '先にどちらか一方をチェックしてから並び替えると、Key の有無で結果が変わります。',
          ),
          const SizedBox(height: 16),
          for (final worker in _workers)
            Padding(
              padding: const EdgeInsets.only(bottom: 12),
              child: WorkerCard(
                key: _useKeys ? ValueKey(worker.id) : null,
                worker: worker,
              ),
            ),
        ],
      ),
    );
  }
}

class WorkerCard extends StatefulWidget {
  const WorkerCard({
    super.key,
    required this.worker,
  });

  final Worker worker;

  @override
  State<WorkerCard> createState() => _WorkerCardState();
}

class _WorkerCardState extends State<WorkerCard> {
  bool _checked = false;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            CircleAvatar(child: Text(widget.worker.id)),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    widget.worker.name,
                    style: const TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 4),
                  const Text('チェック状態は各カードの内部 state です。'),
                ],
              ),
            ),
            Checkbox(
              value: _checked,
              onChanged: (value) {
                setState(() {
                  _checked = value ?? false;
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}

コードのポイント

ValueKey の有無で child の対応付けが変わる

          for (final worker in _workers)
            Padding(
              padding: const EdgeInsets.only(bottom: 12),
              child: WorkerCard(
                key: _useKeys ? ValueKey(worker.id) : null,
                worker: worker,
              ),
            ),

ここで ValueKey(worker.id) を付けずに順番を入れ替えると、Flutter は「先頭の子は先頭の子のまま」とみなしやすく、チェック状態が位置に残ります。ValueKey を付けると「このカードは worker A 用」「このカードは worker B 用」という対応付けが明示され、state がデータ側へ追従します。

② 子の state は内部にあり、Key がその帰属先を決める

class _WorkerCardState extends State<WorkerCard> {
  bool _checked = false;

  @override
  Widget build(BuildContext context) {

チェック状態は各カードの内部 state であり、Key 自体が state を持っているわけではありません。Key は前回の child と今回の child をどう対応付けるかのヒントで、だからこそ並び替えや差し替えの場面で効いてきます。

Keyなしでの並び替え ValueKey付きでの並び替え

6. Key の選び方を最小限に絞る

最初に覚える Key の使い分けは、次の表で十分です。

Key使う場面最初の判断
ValueKey(value)ID、コード、インデックス以外の安定した値がある一覧や並び替えではまずこれを考える
ObjectKey(object)オブジェクト自体の同一性で区別したい値よりオブジェクト参照を基準にしたいときだけ使う
UniqueKey()毎回別物として扱いたい強制的に作り直したいときだけ使う
GlobalKey()特定の StateFormState に外からアクセスしたい常用しない。必要性がはっきりしたときだけ使う

多くの一覧系では ValueKey(item.id) が第一候補。UniqueKey() を何となく付けると、毎回別物として扱われて state を引き継げなくなります。GlobalKey() は便利に見えますが、参照のつながりが強くなるため、最初の一般解には向きません。

迷ったときは次の順で考えると整理しやすくなります。

  1. 並び替えや差し替えがあるか
  2. どの値を「同じデータ」とみなすか
  3. その値を ValueKey にできるか

この 3 つで足りるなら、初学者向けの範囲としては十分です。

7. まとめ

BuildContext は Widget ツリー上の位置、Key は Widget の同一性を区別する手掛かりです。この 2 つを切り分けておくと、Buildercontext を取り直す場面や、並び替えで ValueKey(item.id) を使う場面の判断がしやすくなります。

次に UI を組み立てる基礎を固めるなら Flutterのレイアウト入門(Column / Row / Stack の使い分け) がつながります。Navigator.of(context)go_router の判断へ進むなら Flutterのルーティング入門(Navigator と go_router の使い分け) を読むと、今回の BuildContext の理解をそのまま使えます。Widget 分割と組み合わせて考えるなら Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用) も有効です。

シリーズ 7/14

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する 現在の記事
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える