DartのStream入門(非同期データの流れをつかむ) の次に止まりやすいのが、StatefulWidget のライフサイクルです。initState / build / dispose で迷いやすいのは、名前よりも処理の置き場です。初回読込をどこで始めるか、TextEditingController や Timer をどこで閉じるか、その置き場が曖昧なまま書き始めるところにあります。この記事では initState / build / dispose の役割を、再現しやすい最小例で順番に整理します。
1. ゴールと非対象
対象読者
- Flutter の環境構築と Dart 基礎までは終わった人
StatefulWidgetを書き始めたが、API 呼び出しや Controller の置き場で止まりやすい人- REST API 通信や状態管理へ進む前に、画面の寿命に合わせた書き方を固めたい人
この記事で到達する状態
initStateとbuildとdisposeの役割差を説明できる- 初回読込を
initStateへ寄せる理由を説明できる TextEditingControllerやTimerをdisposeで閉じる理由が分かるawaitのあとにmountedを確認する意味を説明できる
非対象
didChangeDependencies/didUpdateWidgetの詳細AnimationControllerやTickerProviderStateMixin- Riverpod / Bloc などの状態管理ライブラリ
BuildContextとKeyの深掘り
今回は Widget ライフサイクル全体を網羅しません。最初の 1 本で繰り返し出てくる initState / build / dispose に絞ります。ここが整理できると、後続の REST API、フォーム、状態管理の記事でも迷いを減らせる土台になります。
2. 先にライフサイクルの流れを掴む
StatefulWidget で最初に見る流れは、次の 4 段階だけで十分です。
| フック | いつ呼ばれるか | 最初に置くもの |
|---|---|---|
createState | StatefulWidget から State を作るとき | 通常は意識しなくてよい |
initState | State が作られた直後、1 回だけ | 初回読込、購読開始、Controller の初期設定 |
build | 画面を描くたび。setState のたびに再実行される | UI の組み立て |
dispose | 画面が破棄される直前、1 回だけ | Controller / Timer / Subscription の後始末 |
flowchart TD
A[StatefulWidget が mount される] --> B[createState]
B --> C[initState]
C --> D[build]
D --> E{setState や親Widgetの更新があるか}
E -->|Yes| D
E -->|No| F[画面表示を継続]
F --> G{画面が閉じられるか}
G -->|Yes| H[dispose]
G -->|No| F
最初に押さえたい点は 2 つです。
buildは 1 回だけではない。再描画のたびに何度でも呼ばれるdisposeは「画面の寿命に合わせて閉じるもの」の置き場になる
この 2 点が見えていないと、build に API 呼び出しを書いて再読込を繰り返したり、TextEditingController を閉じ忘れたりしやすくなります。
以降のコード例はすべて lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要のため、DartPad でも動作確認できます。コードは null safety 有効の環境(Flutter 3 以降)を前提にしています。
3. build に副作用を書くと再描画のたびに詰まりやすい
最初に、動くが置き場がよくない例を見ます。FutureBuilder の future を build 内で毎回作る形です。
lib/main.dart は次の内容で作成します。
このファイルは、build の中で毎回新しい Future を作ると何が起きるかを再現するための悪い例です。レイアウト変更用の setState だけで読込までやり直される構造にしてあります。
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: 'Bad lifecycle sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
),
home: const BadFutureBuilderPage(),
);
}
}
class BadFutureBuilderPage extends StatefulWidget {
const BadFutureBuilderPage({super.key});
@override
State<BadFutureBuilderPage> createState() => _BadFutureBuilderPageState();
}
class _BadFutureBuilderPageState extends State<BadFutureBuilderPage> {
bool _showDetailPanel = true;
int _requestCount = 0;
Future<String> _loadShipmentSummary() async {
_requestCount += 1;
final requestNo = _requestCount;
await Future<void>.delayed(const Duration(seconds: 1));
return '未処理 12 件 / 要確認 3 件 / request #$requestNo';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('build に future を置く例')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'この画面はボタンで setState すると build が再実行されます。',
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {
setState(() {
_showDetailPanel = !_showDetailPanel;
});
},
child: Text(
_showDetailPanel ? '詳細パネルを隠す' : '詳細パネルを表示する',
),
),
const SizedBox(height: 16),
FutureBuilder<String>(
future: _loadShipmentSummary(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('出荷概要を読み込んでいます...'),
],
),
),
);
}
return Card(
child: ListTile(
title: const Text('出荷概要'),
subtitle: Text(snapshot.data ?? 'データなし'),
),
);
},
),
const SizedBox(height: 16),
if (_showDetailPanel)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('ここはレイアウト確認用の詳細パネルです。'),
),
),
],
),
),
);
}
}
コードのポイント
① build のたびに FutureBuilder へ新しい Future を渡している
FutureBuilder<String>(
future: _loadShipmentSummary(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
FutureBuilder 自体が悪いのではなく、future: に毎回新しい Future を渡していることが問題です。build が再実行されるたびに _loadShipmentSummary() も呼ばれるため、読込の起点が UI 再描画へ巻き込まれます。
② レイアウト切り替え用の setState でも再読込が走ってしまう
ElevatedButton(
onPressed: () {
setState(() {
_showDetailPanel = !_showDetailPanel;
});
},
この setState 自体は詳細パネルの表示切り替えだけが目的です。ところが読込処理の起点が build にあるため、レイアウトを少し変えただけでも API 呼び出し相当の処理まで巻き戻されます。
「詳細パネルを隠す」を 1 回押しただけで、次の 3 枚のように読込がやり直されます。
初学者が最初に混同しやすいのは、「初回だけ読みたい」処理と「再描画のたびに呼ばれてよい」処理を同じ場所へ置いてしまうことです。ここで initState の役割が必要になります。
4. initState で初回読込を固定する
次は同じ題材を、initState で 1 回だけ Future を作る形へ直します。
lib/main.dart は次の内容で作成します。
このファイルは、初回読込の起点を initState へ移し、再描画と再読込を切り分けるための最小例です。意図したときだけ _reload() で新しい Future に差し替える構造にしています。
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: 'Good lifecycle sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const GoodFutureBuilderPage(),
);
}
}
class GoodFutureBuilderPage extends StatefulWidget {
const GoodFutureBuilderPage({super.key});
@override
State<GoodFutureBuilderPage> createState() => _GoodFutureBuilderPageState();
}
class _GoodFutureBuilderPageState extends State<GoodFutureBuilderPage> {
late Future<String> _summaryFuture;
bool _showDetailPanel = true;
int _requestCount = 0;
@override
void initState() {
super.initState();
_summaryFuture = _loadShipmentSummary();
}
Future<String> _loadShipmentSummary() async {
_requestCount += 1;
final requestNo = _requestCount;
await Future<void>.delayed(const Duration(seconds: 1));
return '未処理 12 件 / 要確認 3 件 / request #$requestNo';
}
void _reload() {
setState(() {
_summaryFuture = _loadShipmentSummary();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('initState で future を保持する例')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'詳細パネルの表示切り替えでは再読込せず、再読込ボタンを押したときだけ future を差し替えます。',
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ElevatedButton(
onPressed: () {
setState(() {
_showDetailPanel = !_showDetailPanel;
});
},
child: Text(
_showDetailPanel ? '詳細パネルを隠す' : '詳細パネルを表示する',
),
),
OutlinedButton(
onPressed: _reload,
child: const Text('明示的に再読込する'),
),
],
),
const SizedBox(height: 16),
FutureBuilder<String>(
future: _summaryFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('出荷概要を読み込んでいます...'),
],
),
),
);
}
return Card(
child: ListTile(
title: const Text('出荷概要'),
subtitle: Text(snapshot.data ?? 'データなし'),
),
);
},
),
const SizedBox(height: 16),
if (_showDetailPanel)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('ここはレイアウト確認用の詳細パネルです。'),
),
),
],
),
),
);
}
}
コードのポイント
① 初回読込は initState で 1 回だけ始める
@override
void initState() {
super.initState();
_summaryFuture = _loadShipmentSummary();
}
初回読込の起点を initState へ置くと、画面が作られた直後の 1 回だけ Future を生成できます。build に処理を置かないため、以後の再描画がそのまま再読込になるのを避けられます。
② FutureBuilder は保持済みの Future を参照するだけにする
FutureBuilder<String>(
future: _summaryFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
FutureBuilder 側は、新規読込の開始ではなく「いま保持している非同期結果を描画する役目」に絞られます。これでレイアウト変更とデータ読込の責務が分離され、状態変化の追跡が楽になります。
③ 再読込したいときだけ _reload() で差し替える
void _reload() {
setState(() {
_summaryFuture = _loadShipmentSummary();
});
}
setState を完全に避けるのではなく、再読込したい場面へだけ用途を絞っているのがポイントです。明示的な再読込ボタンからこのメソッドを呼ぶ構造にすると、どの操作がネットワーク相当の処理を起こすかを本文でも説明しやすくなります。
次の 2 枚は初回表示とパネル切り替え後の状態です。どちらも request #1 のままで、再読込が起きていないことを確認できます。
initState に置くものの目安は、「その画面が開いた直後に 1 回だけ必要で、再描画のたびにやり直す必要がない処理」です。API の初回読込、Stream の購読開始、Controller の初期値設定はここに入りやすくなります。
一方で、showDialog や SnackBar のように BuildContext を使った表示操作を initState へ直接詰め込むと、描画タイミングと競合しやすくなります。こうした UI 操作は、まず通常のイベントハンドラから始めるほうが安全です。
5. dispose で Controller と Timer を閉じる
次は、画面の寿命にひもづくオブジェクトを dispose で閉じる例です。TextEditingController と Timer を使うと、後始末のイメージを作りやすくなります。
lib/main.dart は次の内容で作成します。
このファイルは、画面の寿命に合わせて TextEditingController と Timer を開始し、dispose で閉じる流れをまとめて確認するための最小例です。非同期復元処理も入れてあり、mounted の確認が必要になる場面も再現しています。
import 'dart:async';
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: 'dispose sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const ShipmentMonitorPage(),
);
}
}
class ShipmentMonitorPage extends StatefulWidget {
const ShipmentMonitorPage({super.key});
@override
State<ShipmentMonitorPage> createState() => _ShipmentMonitorPageState();
}
class _ShipmentMonitorPageState extends State<ShipmentMonitorPage> {
final TextEditingController _keywordController = TextEditingController();
Timer? _pollingTimer;
int _elapsedSeconds = 0;
String _connectionStatus = '接続確認前';
String _restoreMessage = '下書き未復元';
@override
void initState() {
super.initState();
_keywordController.text = 'A-1001';
_startPolling();
_restoreDraft();
}
void _startPolling() {
_pollingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
_elapsedSeconds += 1;
_connectionStatus =
_elapsedSeconds.isEven ? '端末と接続中' : 'サーバー応答待ち';
});
});
}
Future<void> _restoreDraft() async {
await Future<void>.delayed(const Duration(milliseconds: 800));
if (!mounted) {
return;
}
setState(() {
_restoreMessage = '前回入力した検索条件を復元しました';
});
}
@override
void dispose() {
_pollingTimer?.cancel();
_keywordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('dispose の最小例')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'検索条件',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
TextField(
controller: _keywordController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '受注番号',
),
),
const SizedBox(height: 12),
Text(_restoreMessage),
],
),
),
),
const SizedBox(height: 16),
Card(
child: ListTile(
title: const Text('接続状態'),
subtitle: Text(_connectionStatus),
trailing: Text('${_elapsedSeconds}s'),
),
),
const SizedBox(height: 16),
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'この画面では initState で Timer を開始し、dispose で停止します。\n'
'TextEditingController も同じく dispose で閉じます。',
),
),
),
],
),
);
}
}
コードのポイント
① initState で Controller 初期化と継続処理の開始をまとめる
@override
void initState() {
super.initState();
_keywordController.text = 'A-1001';
_startPolling();
_restoreDraft();
}
画面が開いた直後に 1 回だけ必要な処理を initState へまとめています。初期値設定、Timer 開始、下書き復元のように、画面の開始と一緒に走らせたい処理はここへ寄せると見通しが安定します。
② await のあとに mounted を確認してから setState する
Future<void> _restoreDraft() async {
await Future<void>.delayed(const Duration(milliseconds: 800));
if (!mounted) {
return;
}
非同期処理の完了を待っている間に画面が閉じることは普通にあります。mounted を挟んでおけば、存在しない画面へ setState して例外になる経路を先に断てます。
③ dispose で Timer と Controller を確実に閉じる
@override
void dispose() {
_pollingTimer?.cancel();
_keywordController.dispose();
super.dispose();
}
継続的に動く Timer と、画面にひもづく TextEditingController は、画面終了時にここで後始末します。dispose を出口として固定しておくと、どのオブジェクトが寿命管理の対象かをコードから読み取りやすくなります。
mounted は「この State がまだ画面ツリーに残っているか」を表します。Future.delayed や API 呼び出しの完了を待っている間に画面が閉じられることは普通にあります。その状態で setState すると、存在しない画面を更新しようとする形です。
dispose で閉じる代表例は、次のとおりです。
TextEditingControllerScrollControllerAnimationControllerStreamSubscriptionTimer
逆に、単なる String や int のフィールドは dispose 不要です。閉じる必要があるのは、画面外へリソースを持つオブジェクトや、継続的に動いている処理です。
6. 詰まりやすい点をチェックリストで整理する
最後に、StatefulWidget で最初に踏みやすい点だけをまとめます。
super.initState()はinitStateの先頭で呼ぶsuper.dispose()はdisposeの最後で呼ぶbuildに API 呼び出し、購読開始、Timer作成を書かないdisposeはasyncにしない。同期的に閉じられるものをここで閉じるawaitのあとでsetStateする前にif (!mounted) return;を挟むshowDialogやSnackBarのような表示操作をinitStateへ直接詰め込みすぎない
特に最初の 3 つだけでも、現場で遭遇しやすい不具合を大きく減らせます。build は描画、initState は開始、dispose は終了。この三分割が見えていれば、StatefulWidget は追いやすくなります。
7. まとめ
StatefulWidget の入口では、すべてのライフサイクルを覚える必要はありません。まずは「初回だけやることは initState」「画面を組み立てるのは build」「閉じる処理は dispose」の 3 点で十分です。
非同期データの購読開始と終了がまだ曖昧なら DartのStream入門(非同期データの流れをつかむ) を先に見直すとつながります。次は Flutterのレイアウト入門(Column / Row / Stack の使い分け) へ進むと、状態を持つ画面の中で何をどう並べるかを整理しやすくなります。REST API 記事を読み直すときも、initState と dispose の役割が見えているほうがコードを追いやすくなります。