FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために) の次に止まりやすいのが、BuildContext と Key です。BuildContext と Key で迷いやすいのは、名前よりも役割の違いです。context が何を指しているのか曖昧なまま Scaffold.of(context) や Navigator.of(context) を呼んだり、一覧の並び替えで state が思った位置についてこなかったりする点にあります。この記事では BuildContext を「Widget ツリー上の位置」、Key を「Widget の同一性」として切り分け、最初にぶつかりやすい場面だけを最小例で整理します。
1. ゴールと非対象
対象読者
- Flutter の環境構築と Dart 基礎は終わった人
StatefulWidgetを少し書き始めたが、BuildContextとKeyの説明が抽象的で実装へ結びつきにくい人- レイアウト、カスタム Widget、ルーティングへ進む前に、Widget ツリーの基本的な見方を固めたい人
この記事で到達する状態
BuildContextを「今いる場所」として説明できるBuilderや子 Widget 切り出しで、必要な位置のcontextを取り直せるawaitのあとにcontext.mountedを確認する意味が分かる- 一覧の並び替えで
ValueKeyが必要になる理由を説明できる ValueKey/ObjectKey/UniqueKey/GlobalKeyの違いを大まかに選び分けられる
非対象
- Element / RenderObject の内部実装
InheritedWidgetの自作- Riverpod / Bloc などの状態管理ライブラリ
GlobalKeyを使った高度な制御- パフォーマンス最適化の深掘り
今回は Flutter 内部の実装を追いません。BuildContext と Key がどの場面で何を見分けるための仕組みかを把握します。ここが整理できると、後続のレイアウト、ルーティング、一覧 UI でも迷いが減ります。
2. 先に BuildContext と Key の役割を切り分ける
まずは 2 つの役割を分けます。
| 概念 | 役割 | 最初に出会う場面 | 見るべき点 |
|---|---|---|---|
BuildContext | Widget ツリー上の位置 | Theme.of(context) Navigator.of(context) Scaffold.of(context) | その context から親方向に何が見えるか |
Key | Widget の同一性を区別する手掛かり | リスト並び替え、差し替え、フォーム、アニメーション | どの子 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) です。StatelessWidget や StatefulWidget の build 引数でもらう context は、その Widget 自身の位置を指します。build の中でこれから返す Scaffold より上にあるため、そこで Scaffold.of(context) を呼んでも、その Scaffold にはまだ届かない点に注意が必要です。
lib/main.dart は次の内容で作成します。
このファイルは、BuildContext が Widget ツリー上のどこを指しているかを Drawer 操作で確かめるサンプルです。注目点は、同じ画面でも pageContext と innerContext では見える親 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,
),
),
),
],
);
},
),
],
),
),
);
}
}
コードのポイント
① pageContext は Scaffold より上にある
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 の違いではなく、pageContext が Scaffold の外側にあるという位置関係です。
② Builder の innerContext で初めて Drawer に届く
Builder(
builder: (innerContext) {
final theme = Theme.of(innerContext);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton.icon(
onPressed: () => Scaffold.of(innerContext).openDrawer(),
Builder を 1 枚挟むことで、innerContext は Scaffold の内側に置かれます。BuildContext を「画面全体の共通参照」と見るとこの差は見えませんが、位置として捉えると、同じ画面でも見える親方向の範囲が違うことが整理しやすくなります。
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 を値の受け渡しだけに見ているとこの確認は抜けやすいですが、位置情報であり画面の寿命と結び付いていると理解すると、非同期処理のあとに何を確認すべきかが見えやすくなります。
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 をどう対応付けるかのヒントで、だからこそ並び替えや差し替えの場面で効いてきます。
6. Key の選び方を最小限に絞る
最初に覚える Key の使い分けは、次の表で十分です。
| Key | 使う場面 | 最初の判断 |
|---|---|---|
ValueKey(value) | ID、コード、インデックス以外の安定した値がある | 一覧や並び替えではまずこれを考える |
ObjectKey(object) | オブジェクト自体の同一性で区別したい | 値よりオブジェクト参照を基準にしたいときだけ使う |
UniqueKey() | 毎回別物として扱いたい | 強制的に作り直したいときだけ使う |
GlobalKey() | 特定の State や FormState に外からアクセスしたい | 常用しない。必要性がはっきりしたときだけ使う |
多くの一覧系では ValueKey(item.id) が第一候補。UniqueKey() を何となく付けると、毎回別物として扱われて state を引き継げなくなります。GlobalKey() は便利に見えますが、参照のつながりが強くなるため、最初の一般解には向きません。
迷ったときは次の順で考えると整理しやすくなります。
- 並び替えや差し替えがあるか
- どの値を「同じデータ」とみなすか
- その値を
ValueKeyにできるか
この 3 つで足りるなら、初学者向けの範囲としては十分です。
7. まとめ
BuildContext は Widget ツリー上の位置、Key は Widget の同一性を区別する手掛かりです。この 2 つを切り分けておくと、Builder で context を取り直す場面や、並び替えで ValueKey(item.id) を使う場面の判断がしやすくなります。
次に UI を組み立てる基礎を固めるなら Flutterのレイアウト入門(Column / Row / Stack の使い分け) がつながります。Navigator.of(context) や go_router の判断へ進むなら Flutterのルーティング入門(Navigator と go_router の使い分け) を読むと、今回の BuildContext の理解をそのまま使えます。Widget 分割と組み合わせて考えるなら Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用) も有効です。