公開日 2026-06-19

FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)

Flutter の StatefulWidget で initState / build / dispose の役割を最小例で整理し、初回読込の置き場と Controller の後始末で迷わないようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先にライフサイクルの流れを掴む
  6. 3. build に副作用を書くと再描画のたびに詰まりやすい
  7. コードのポイント
  8. 4. initState で初回読込を固定する
  9. コードのポイント
  10. 5. dispose で Controller と Timer を閉じる
  11. コードのポイント
  12. 6. 詰まりやすい点をチェックリストで整理する
  13. 7. まとめ

DartのStream入門(非同期データの流れをつかむ) の次に止まりやすいのが、StatefulWidget のライフサイクルです。initState / build / dispose で迷いやすいのは、名前よりも処理の置き場です。初回読込をどこで始めるか、TextEditingControllerTimer をどこで閉じるか、その置き場が曖昧なまま書き始めるところにあります。この記事では initState / build / dispose の役割を、再現しやすい最小例で順番に整理します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築と Dart 基礎までは終わった人
  • StatefulWidget を書き始めたが、API 呼び出しや Controller の置き場で止まりやすい人
  • REST API 通信や状態管理へ進む前に、画面の寿命に合わせた書き方を固めたい人

この記事で到達する状態

  • initStatebuilddispose の役割差を説明できる
  • 初回読込を initState へ寄せる理由を説明できる
  • TextEditingControllerTimerdispose で閉じる理由が分かる
  • await のあとに mounted を確認する意味を説明できる

非対象

  • didChangeDependencies / didUpdateWidget の詳細
  • AnimationControllerTickerProviderStateMixin
  • Riverpod / Bloc などの状態管理ライブラリ
  • BuildContextKey の深掘り

今回は Widget ライフサイクル全体を網羅しません。最初の 1 本で繰り返し出てくる initState / build / dispose に絞ります。ここが整理できると、後続の REST API、フォーム、状態管理の記事でも迷いを減らせる土台になります。

2. 先にライフサイクルの流れを掴む

StatefulWidget で最初に見る流れは、次の 4 段階だけで十分です。

フックいつ呼ばれるか最初に置くもの
createStateStatefulWidget から State を作るとき通常は意識しなくてよい
initStateState が作られた直後、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 に副作用を書くと再描画のたびに詰まりやすい

最初に、動くが置き場がよくない例を見ます。FutureBuilderfuturebuild 内で毎回作る形です。

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 枚のように読込がやり直されます。

初回表示: request #1 が読み込まれた状態 ボタン押下直後: build 再実行でローディングが始まった状態 再読込完了: request #2 に変わっている

初学者が最初に混同しやすいのは、「初回だけ読みたい」処理と「再描画のたびに呼ばれてよい」処理を同じ場所へ置いてしまうことです。ここで 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 のままで、再読込が起きていないことを確認できます。

初回表示: request #1 が読み込まれた状態 パネル切り替え後: request #1 のまま変わらない

initState に置くものの目安は、「その画面が開いた直後に 1 回だけ必要で、再描画のたびにやり直す必要がない処理」です。API の初回読込、Stream の購読開始、Controller の初期値設定はここに入りやすくなります。

一方で、showDialogSnackBar のように BuildContext を使った表示操作を initState へ直接詰め込むと、描画タイミングと競合しやすくなります。こうした UI 操作は、まず通常のイベントハンドラから始めるほうが安全です。

5. dispose で Controller と Timer を閉じる

次は、画面の寿命にひもづくオブジェクトを dispose で閉じる例です。TextEditingControllerTimer を使うと、後始末のイメージを作りやすくなります。

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

このファイルは、画面の寿命に合わせて TextEditingControllerTimer を開始し、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 の最小例: TextEditingController と Timer が動いている状態

dispose で閉じる代表例は、次のとおりです。

  • TextEditingController
  • ScrollController
  • AnimationController
  • StreamSubscription
  • Timer

逆に、単なる Stringint のフィールドは dispose 不要です。閉じる必要があるのは、画面外へリソースを持つオブジェクトや、継続的に動いている処理です。

6. 詰まりやすい点をチェックリストで整理する

最後に、StatefulWidget で最初に踏みやすい点だけをまとめます。

  • super.initState()initState の先頭で呼ぶ
  • super.dispose()dispose の最後で呼ぶ
  • build に API 呼び出し、購読開始、Timer 作成を書かない
  • disposeasync にしない。同期的に閉じられるものをここで閉じる
  • await のあとで setState する前に if (!mounted) return; を挟む
  • showDialogSnackBar のような表示操作を initState へ直接詰め込みすぎない

特に最初の 3 つだけでも、現場で遭遇しやすい不具合を大きく減らせます。build は描画、initState は開始、dispose は終了。この三分割が見えていれば、StatefulWidget は追いやすくなります。

7. まとめ

StatefulWidget の入口では、すべてのライフサイクルを覚える必要はありません。まずは「初回だけやることは initState」「画面を組み立てるのは build」「閉じる処理は dispose」の 3 点で十分です。

非同期データの購読開始と終了がまだ曖昧なら DartのStream入門(非同期データの流れをつかむ) を先に見直すとつながります。次は Flutterのレイアウト入門(Column / Row / Stack の使い分け) へ進むと、状態を持つ画面の中で何をどう並べるかを整理しやすくなります。REST API 記事を読み直すときも、initStatedispose の役割が見えているほうがコードを追いやすくなります。

シリーズ 6/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で複数画面を切り替える