Flutterでウィジェットを1つ書くたびに、StatelessWidgetとStatefulWidgetのどちらを継承するかを決めることになります。判断を外すと、入力した値が再ビルドのたびに消えたり、変化しない部分まで毎回作り直したりします。この記事では、Flutter 3.47.9(Dart 3.13.7、2026年10月29日UTCリリース)のフレームワーク実装と、手元で実行したflutter test・flutter analyzeの結果をもとに、両者の判定基準、状態をどのウィジェットに持たせるか、再ビルドを減らす書き方、setStateとライフサイクルの実際の挙動を順に整理します。
まとめ
判断材料は1つだけです。そのウィジェット自身が、再ビルドをまたいで覚えておかなければならない値を持つか。持つならStatefulWidget、持たないならStatelessWidgetです。親から渡されるだけの色やラベルは「覚えておく値」ではないので、StatelessWidgetで足ります。チェック状態や入力値のようなユーザーデータは、公式ガイドの指針どおり親に持たせ、子はStatelessWidgetにして値とコールバックを受け取る形が基本です。
実装面で押さえるのは次の点です。
constを付けた子は親が10回setStateし、毎回1フレーム進めた実測では、buildは初回の1回だけ。非constの子は11回(依存するInheritedWidgetの変化がない条件)- UIの部品をhelper methodで返すと親の再ビルドのたびに11回実行され、widgetクラスに切り出して
constで置くと1回で止まる setStateは即時の再描画ではなく再ビルドの予約で、実際のbuildは次のフレーム。同じフレームで3回呼んでもbuildは1回- ライフサイクルはマウント時が
initState→didChangeDependencies→build、親の作り直しでdidUpdateWidget→build、破棄でdeactivate→dispose - 画面をまたぐ状態は共有方法を検討する。StatefulWidgetでも保持できるが、値やコールバックの受け渡しが複雑になる場合は管理方法を分離する
StatelessWidgetとStatefulWidgetの判定基準
状態の寿命で決まる継承先の選択
両者の差は「内部に可変の状態を置ける場所があるかどうか」の一点です。StatelessWidgetが持つのはbuildだけで、フィールドはfinal固定です。StatefulWidgetは自分とは別にStateオブジェクトを持ち、同じツリー位置のウィジェットを同じ型・同じkeyで更新する場合、既存のStateが再利用されます。だから値が残ります。
Statelessは「表示が変わらない」という意味ではありません。自分では変更のきっかけを持たないという意味です。StatelessWidgetの公式APIリファレンスは、buildが呼ばれる場面を「ツリーへの初回挿入時」「親が設定を変えたとき」「依存しているInheritedWidgetが変わったとき」の3つと説明しています。親が新しい値で作り直せば表示は変わりますし、Widgetクラスに@immutableが付いているため、finalでないフィールドを置くとmust_be_immutableの指摘が出ます。変わらないのはウィジェットのインスタンスそのものです。
| 比較軸 | StatelessWidget | StatefulWidget |
|---|---|---|
| 再ビルドをまたぐ内部状態 | 持てない | Stateが保持 |
| 必須のオーバーライド | build | createState |
| 更新の起点 | 親の再ビルド+依存InheritedWidgetの変化 | 左記+setState |
| constコンストラクタ | 可 | 可(Stateは対象外) |
| 破棄時の後始末 | 不要 | disposeで解放 |
3つ目の起点は見落とされがちです。Element.didChangeDependenciesはmarkNeedsBuildを呼ぶ実装で(Flutter 3.47.9のframework.dartの5203〜5206行目)、StatelessElementはこれを上書きしていません。constで固定した子がTheme.of(context)を読む構成で、MaterialAppのthemeだけを明るいテーマから暗いテーマへ差し替えたところ、子のbuildは1回から2回に増えました(themeAnimationDuration: Duration.zeroで計測。既定のテーマ切り替えアニメーションを残すと補間中のフレームでも再ビルドされ、4回でした)。
実務で効くのは表の最終行です。Timer、StreamSubscription、AnimationControllerのように解放が要るものを持った時点で、StatefulWidgetにしてdisposeで止める必要が出ます。解放するものも覚えておく値も無いなら、StatefulWidgetにする理由はありません。
迷ったらStatelessWidgetから始めるべき理由
判断がつかない段階ではStatelessWidgetで書き始めてください。後戻りのコストが非対称だからです。StatelessからStatefulへの変更は、クラスを2つに割って可変フィールドをStateへ移すだけで、呼び出し側のコードは変わりません。逆向きは、setStateの呼び出し箇所を全部たどって「本当に自前で持つべき状態だったか」を1つずつ判定し直す作業になります。
「将来状態を持ちそうだから、とりあえずStatefulWidgetにしておく」書き方は避けます。使っていないStateクラスはコストを払うだけで何も返しません。
状態の配置先と自身・親・混在の選択基準
公式ガイドの3方式と「迷ったら親」の指針
StatefulWidgetにすると決めたあとにもう1つ判断があります。状態を、表示するウィジェット自身が持つのか、親が持つのかです。公式ガイドのAdding interactivityは、方式を「ウィジェット自身が管理」「親が管理」「両者の混在」の3つに分け、次の基準を示しています。
- チェックボックスのオン・オフやスライダーの位置のようなユーザーデータは親が管理する
- アニメーションのような見た目だけの状態はウィジェット自身が管理する
- 迷ったら親で管理するところから始める(原文は “If in doubt, start by managing state in the parent widget.”)
前章の「迷ったらStatelessWidget」と同じ向きの指針です。子が状態を持たなければ子はStatelessWidgetのまま書け、値の持ち主が1か所にまとまります。保存ボタンや入力チェックのように同じ値を別の部品から読む必要が出ても、持ち主を探し回らずに済みます。
親が状態を持つStatelessWidgetの書き方
親管理の形では、子は現在値とコールバックだけを受け取ります。お気に入りの星ボタンを例にすると次のとおりです。
import 'package:flutter/material.dart';
class FavoriteToggle extends StatelessWidget {
const FavoriteToggle({
super.key,
required this.active,
required this.onChanged,
});
final bool active;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(active ? Icons.star : Icons.star_border),
onPressed: () => onChanged(!active),
);
}
}
class ProductTile extends StatefulWidget {
const ProductTile({super.key, required this.name});
final String name;
@override
State<ProductTile> createState() => _ProductTileState();
}
class _ProductTileState extends State<ProductTile> {
bool _favorite = false;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(widget.name),
trailing: FavoriteToggle(
active: _favorite,
onChanged: (bool value) {
setState(() {
_favorite = value;
});
},
),
);
}
}
このコードはFlutter 3.47.9のflutter analyzeでNo issues found!、ウィジェットテストでタップ前がstar_border、タップ後がstarに切り替わることを確認しています。FavoriteToggleはactiveを自分で書き換えず、onChangedで親に変更を依頼するだけです。Flutter標準のCheckboxやSwitchも、値をvalueで受け取り、変更をonChangedで親へ返すAPIです。
逆に、展開アニメーションの進行度やホバー中かどうかのように親が知る必要のない状態は、子のStatefulWidgetに閉じ込めます。これが「混在」で、ユーザーデータは親、見た目の状態は子という分担になります。
StatelessWidgetの最小実装とconstが再ビルドを止める仕組み
公式テンプレートに合わせた書き方とsuper.key
StatelessWidgetはbuildのオーバーライド1つで完結します。flutter createのテンプレート(SDK内のpackages/flutter_tools/templates/app/lib/main.dart.tmpl)では、コンストラクタにconstとsuper.keyが付き、フィールドはfinalです。この形をそのまま真似るのが最短です。
import 'package:flutter/material.dart';
class GreetingLabel extends StatelessWidget {
const GreetingLabel({super.key, required this.name});
final String name;
@override
Widget build(BuildContext context) {
return Text('Hello, $name');
}
}
公開ウィジェットのコンストラクタに名前付きのkey引数がないと、use_key_in_widget_constructorsの指摘が出ます。ここではsuper.keyでその引数を宣言しています。3.47.9のテンプレートが生成するpubspec.yamlはflutter_lints: ^6.0.0を指定しており、このルールはflutter_lints 6.0.0のflutter.yamlに含まれています。
親を10回更新した場合のconstの子と非constの子のbuild回数
constの効果はソースを追うとはっきりします。Element.updateChildは新旧のウィジェットがchild.widget == newWidgetを満たすと、その子ツリーの更新を丸ごと飛ばします(framework.dart 4027行目)。この==はWidgetクラスで@nonVirtualが付き、Objectの同一性判定に委譲されています(364〜366行目)。標準実装では「同じインスタンスかどうか」を比較します。@nonVirtualに反するオーバーライドは解析時に指摘されるため、値比較へ変更する設計は避けてください。
Dartのconst式は同じ内容なら同一インスタンスに正規化されます。const GreetingLabel(name: 'a')と書いた箇所は、親が何度buildされても同じインスタンスを返すので、子ツリーがスキップされます。buildの回数を数える子を2つ用意し、片方だけconstを付けて親から10回setStateを呼んだ結果がこちらです。
RESULT constChildBuilds=1 plainChildBuilds=11
const付きは初回の1回だけ、constなしは初回+10回の11回です。固定文言やアイコンのように設定が変わらない部分は、constにすると、親の再ビルドに伴う子の更新を省略できます。ただし、子自身が依存するInheritedWidgetの変化による再ビルドは防ぎません。
ここは標準のlint設定だけでは呼び出し側のconst付け忘れを検出しない領域です。flutter_lintsのprefer_const_constructors_in_immutablesはコンストラクタの宣言側をconstにさせるルールで、呼び出し側の付け忘れは見ません。呼び出し側を促すprefer_const_constructorsは、flutter_lints 6.0.0にも、その土台のlints 6.1.0のrecommended.yamlにも入っていません。上の計測で11回ビルドされた側も、宣言はconst付きです。呼び出し側も検査したい場合は、analysis_options.yamlのlinterでprefer_const_constructorsを個別に有効にします。
条件が成立しなかった場合はWidget.canUpdateに進みます。見ているのはruntimeTypeとkeyの一致だけです(382〜384行目)。両方一致すればElementとStateを再利用し、外れれば作り直します。
再ビルドの範囲を絞るウィジェット分割の書き方
helper methodとwidgetクラスの実行回数比較
StatefulWidgetの公式APIリファレンスのPerformance considerationsは、再利用するUIの部品はWidget _buildHeader()のようなhelper methodではなく、widgetクラスとして書くよう勧めています。helper methodは親のbuildの一部として毎回実行されるからです。固定のTextを「helper methodで返す」「constのwidgetクラスで置く」の2通りで書き、親から10回setStateした結果が次のとおりです。
RESULT_HELPER helperMethodCalls=11 widgetClassBuilds=1
helper methodは11回、widgetクラスは1回です。helper methodの中身がconst Text('header')だけでも、メソッド自体の実行と、その中で組み立てる処理は親の再ビルドごとに走ります。部品が重いほど差が開くので、2回以上使う部品や中で計算をする部品はクラスに切り出してください。1か所でしか使わない数行のPaddingまで分ける必要はありません。
葉のウィジェットへの状態配置と再ビルド範囲
同じリファレンスは「状態を葉へ押し下げる(Push the state to the leaves)」とも書いています。時計を表示するページを例に、秒の値をページ全体のStatefulWidgetに持たせた場合と、時計部分だけのStatefulWidgetに持たせた場合を比べました。どちらも時計を10回更新し、隣に置いたconstなしの兄弟ウィジェットのbuild回数を数えています。
RESULT_LEAF topPage=11 topSibling=11 leafPage=1 clock=11 leafSibling=1
ページに状態を置くとページも兄弟も11回ずつ再ビルドされ、時計の中に移すと再ビルドされるのは時計の11回だけで、ページと兄弟は1回で止まりました。「迷ったら親」と矛盾するように見えますが、対象が違います。親に持たせるのは複数の部品が読むユーザーデータで、時計の秒やアニメーションの進行度のように1つの部品しか読まない値は、その部品の中に閉じ込めます。
StatefulWidgetとStateの分離、setStateが実際に行う処理
createStateの戻り値をState<CounterPanel>と書く理由
StatefulWidgetはウィジェット本体とStateの2クラスで書きます。本体はcreateStateだけを持ち、可変の値はStateに置きます。
import 'package:flutter/material.dart';
class CounterPanel extends StatefulWidget {
const CounterPanel({super.key, this.step = 1});
final int step;
@override
State<CounterPanel> createState() => _CounterPanelState();
}
class _CounterPanelState extends State<CounterPanel> {
int _count = 0;
void _increment() {
setState(() {
_count += widget.step;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text('Count: $_count'),
ElevatedButton(
onPressed: _increment,
child: const Text('Increment'),
),
],
);
}
}
createStateの戻り値型を_CounterPanelStateと書くとlibrary_private_types_in_public_apiに引っかかります(lints 6.1.0のrecommended.yamlに含まれ、flutter_lints経由で有効になります)。公開APIの戻り値にプライベート型を出さないよう、テンプレートと同じくState<CounterPanel>と書きます。
Stateから親のパラメータを読むときはwidget.stepのようにwidgetプロパティを経由します。コンストラクタ引数をStateのフィールドへコピーすると、親が新しい値で作り直してもコピーは古いまま残ります。
setStateは即時再描画ではなく再ビルドの予約
setStateの実装は、渡されたコールバックを同期実行したあと_element!.markNeedsBuild()を呼ぶだけです(framework.dart 1221行目)。markNeedsBuildはElementをdirtyとして登録するだけで、その場ではbuildは走りません。buildの回数をsetStateの直後と1フレーム進めた後で比べ、続けて同じフレームで3回setStateしてから1フレーム進めました。
RESULT_TIMING before=1 afterSetState=1 afterPump=2 after3setStateAnd1pump=3
呼んだ直後は1回のまま、フレームが進んで2回目が走っています。3回呼んでも増えたのは1回だけです。イベントハンドラの中で条件ごとにsetStateを分けて書いても、再ビルドは1回にまとまります。
markNeedsBuildはElementがactiveでなければ何もせず戻ります(5352〜5356行目)。ツリーから一時的に外れた(deactivate済みで、まだunmountされていない)段階でも、setStateのコールバックは同期実行されます。ただし、Elementが非activeの間は、その呼び出しによる再ビルドの予約は行われません。完全に破棄されたあとのsetStateは扱いが変わり、次章のエラーになります。
Stateのライフサイクルと各コールバックが呼ばれる条件
マウント時と親再ビルド時の呼び出し順の実測
各メソッドをログに記録するウィジェットで、マウント・親からの更新・破棄の3局面の呼び出し順をFlutter 3.47.9で記録しました。
RESULT_MOUNT initState > didChangeDependencies > build
RESULT_UPDATE didUpdateWidget(a->b) > build
RESULT_UNMOUNT deactivate > dispose
初回マウントではinitStateのあとにdidChangeDependenciesが必ず1回入ります。ソースのドキュメントにも「initStateの直後にも呼ばれる」とあります(1471行目)。Theme.of(context)やMediaQuery.of(context)を使う初期化は、initStateではなくここに置きます。
親が同じ型・同じkeyで作り直したときはdidUpdateWidgetが古いウィジェットを引数に呼ばれ、initStateは呼ばれません。フレームワークはdidUpdateWidgetのあとに必ずbuildを呼ぶため、中でsetStateを呼ぶのは冗長だとソースのコメントが明言しています(1024行目)。購読先の切り替えのように、古い値と比べて処理を分ける用途に使ってください。
disposeが呼ばれない場面と後始末の書き方
State.disposeのリファレンスは、disposeがアプリの終了時には呼ばれないと注意しています。例に挙げているのはバッテリーの発火、端末をプールに落とす、メモリ逼迫によるOSのプロセス強制終了です。永続化が必要なデータをdisposeで書き出す設計は成り立ちません。アプリのライフサイクルを見るならAppLifecycleListenerを使います。ただし、強制終了時には通知が届かない場合もあるため、重要なデータは変更時などに保存します。
次のコードは、Stream<int> sourceを持つStatefulWidgetのFeedLabelからState部分だけを抜粋したものです。別途dart:asyncとFlutterのウィジェットライブラリをimportし、FeedLabelの定義を用意してください。この例では、同じStateの存続中にsourceを変更しない前提で、initStateで購読し、disposeで解除します。親から別のStreamへ差し替える設計では、didUpdateWidgetで旧購読を解除して新しいStreamを購読します。どちらもsuperの呼び出しを忘れないでください。
class _FeedLabelState extends State<FeedLabel> {
StreamSubscription<int>? _sub;
int _latest = 0;
@override
void initState() {
super.initState();
_sub = widget.source.listen((int value) {
if (!mounted) {
return;
}
setState(() {
_latest = value;
});
});
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text('latest: $_latest');
}
}
破棄したあとのStateにsetStateを呼ぶと、3.47.9のデバッグ実行では次のエラーになりました。同じテストでmountedは破棄の前がtrue、後がfalseです。
setState() called after dispose(): LeakyState#1f4d7(lifecycle state: defunct, not mounted)
このメッセージが出るのはデバッグビルドだけです。判定がassertの中にあるためです(1160〜1164行目)。ただしリリースビルドで無害になるわけではありません。unmount時にstate._element = nullが実行されるので(6056行目)、assertが無効な状態では1221行目の_element!でnullチェックのエラーになるとソースからは読めます(ここは実行ではなく実装からの読み取りです)。
非同期処理のあとのmountedとcontext.mounted
awaitのあとでsetStateやNavigator.of(context)を使うと、待っている間にウィジェットがツリーから外れている可能性があります。setStateやState自身のcontextを使う場合はState.mounted、引数やローカル変数などで受け取った別のcontextを使う場合は、ウィジェットの種類にかかわらず、そのcontextのBuildContext.mountedで確かめます。BuildContext.mountedはタグ付きソースで比べると3.7.0のframework.dartにあり、3.3.0にはありません。それより古い解説に「StatelessWidgetでは確認する手段がない」とあれば、今は当てはまりません。
import 'package:flutter/material.dart';
class SaveButton extends StatelessWidget {
const SaveButton({super.key, required this.save});
final Future<void> Function() save;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
await save();
if (!context.mounted) {
return;
}
Navigator.of(context).pop();
},
child: const Text('Save'),
);
}
}
確認の行を外すと、flutter analyzeはNavigator.of(context)の位置でuse_build_context_synchronouslyを出しました(Don't use 'BuildContext's across async gaps.)。上の形ではNo issues found!です。use_build_context_synchronouslyのルール説明でも、mountedで守るよう案内されています。
flutter analyzeが指摘する典型的なlint違反
入門記事に載っているコードは、そのまま貼るとflutter analyzeの指摘(severityはinfo)が出るものが少なくありません。旧版のこの記事に載っていた2つのサンプル(keyなしのStatelessWidgetと、_CounterWidgetState createState()と書いたStatefulWidget)を、flutter_lints 6.0.0を有効にしたFlutter 3.47.9のプロジェクトで解析した出力です。
info • Constructors for public widgets should have a named 'key' parameter. Try adding a named parameter to the constructor • lib/bad.dart:3:7 • use_key_in_widget_constructors
info • Constructors for public widgets should have a named 'key' parameter. Try adding a named parameter to the constructor • lib/bad.dart:10:7 • use_key_in_widget_constructors
info • Invalid use of a private type in a public API. Try making the private type public, or making the API that uses the private type also be private • lib/bad.dart:12:3 • library_private_types_in_public_api
3 issues found. (ran in 14.5s)
この記事の書き方に直すとNo issues found!になります。直したのはsuper.keyの追加(2件)と、createStateの戻り値型をState<CounterPanel>にしたこと(1件)です。前章のuse_build_context_synchronouslyと合わせ、ウィジェットを書くときに出やすいのはこの3ルールです。
もう1つ覚えておきたいのがno_logic_in_create_stateです。createStateの中でStateに引数を渡したり条件分岐を書いたりする実装を禁止します。createStateはフレームワークがElementを作るときに呼ぶもので、いつ何回呼ばれるかを開発者は制御できません。初期化はinitStateに置いてください。flutter analyzeはinfoだけでも終了コード1を返したので、CIに組み込めば書いた時点で止められます。
共有状態の管理方法を見直す条件と移行先
setStateで足りるのは、状態を持つウィジェットと、それを表示するウィジェットが同じサブツリーに収まっている場合です。開閉状態を持つアコーディオン、フォーム1つ分の入力値、アニメーションの進行度はsetStateのままで十分で、ライブラリは要りません。
本記事では、共通の祖先から値やコールバックを何段も渡す構成になり、変更箇所を追いにくくなった時点を、共有状態の管理方法を見直す目安とします。公式のEphemeral vs app stateも、アプリのすべての状態をStateとsetStateで管理することは可能だとしたうえで、一時的な状態とアプリ全体の状態を分ける明確な規則は無いと書いています。つまり技術的な上限ではなく保守性の判断です。「迷ったら親」で持ち上げ続けた結果、祖先がsetStateするたびに無関係なサブツリーまで再ビルドされる状態になったら、前章の計測で兄弟が11回ビルドされた側の構造に近づいています。ログイン中のユーザー情報、カート、ダークモードの設定のように多くの画面が読む値は、早い段階で外に出す設計を検討してください。
移行先は段階的に選べます。値が1つで購読側も限られるならValueNotifierとValueListenableBuilderで足ります。ThemeやMediaQueryと同じInheritedWidgetを自作すれば、StatelessWidgetのまま祖先の値に追従することもできます。ライブラリを入れるなら、RiverpodとBloc・Providerの違いで候補を比べ、Riverpod 3系の導入と使い方から始めるのが早道です。画面単位で責務を切るところまで進めるなら、公式アーキテクチャガイドに沿ったViewModel設計とFlutterアーキテクチャのベストプラクティスが指針になります。
状態管理ライブラリを入れてもStatefulWidgetは不要になりません。AnimationController、TextEditingController、ScrollControllerのように破棄が必要なオブジェクトは、StateのinitStateとdisposeで管理するのが素直です。既存アプリの設計整理を外部に相談する場合は、Flutter / React Native開発の支援内容も参考にしてください。
よくある質問
StatelessWidgetとStatefulWidgetはどちらを使うべきですか?
そのウィジェット自身が再ビルドをまたいで値を覚える必要があるかで決めます。必要が無ければStatelessWidgetです。チェック状態や入力値は親に持たせ、子はStatelessWidgetで値とコールバックを受け取る形にすると、StatefulWidgetは親の1か所で済みます。
StatelessWidgetの中でsetStateは使えますか?
使えません。setStateはStateクラスのメソッドで、StatelessWidgetには存在しません。画面を変えたいならStatefulWidgetに書き換えるか、親から新しい値を渡して作り直してもらいます。後者のほうが、本記事で示した親管理の形になります。
initStateとbuildはどちらが先に呼ばれますか?
initStateが先です。初回マウント時の順序はinitState→didChangeDependencies→buildでした。Theme.of(context)のようにInheritedWidgetを読む初期化はdidChangeDependenciesに書きます。
setStateを呼んでも画面が更新されないときは何を確認しますか?
更新したい値がStateのフィールドにあるかを確認します。widget側の値は親が作り直さない限り変わりません。次に、組み立て済みのウィジェットをStateのフィールドにキャッシュしていないかを見ます。キャッシュしたインスタンスはElement.updateChildの同一性判定で更新が省略され、手元のテストでもキャッシュしたTextだけが初期値のまま残りました。
リストを並べ替えると入力値が別の行に移ってしまうのはなぜですか?
keyを指定していないためです。Widget.canUpdateはruntimeTypeとkeyの一致だけを見ます。keyを付けないとどちらもnullで一致するため、並べ替えても同じ位置のElementとStateが再利用され、入力値やチェック状態が居残ります。各行にValueKeyなどデータ由来のkeyを与えれば、行とStateの対応が保たれます。
関連記事
- RiverpodとBloc・Providerの違い|GetX・get_itも含めた状態管理の比較と選び方【2026年10月】
- Riverpodとは?Flutterの状態管理をRiverpod 3系で始める導入・使い方・移行ガイド
- FlutterのMVVM実装をRiverpodで書く手順|公式アーキテクチャガイド準拠のViewModel設計
- Flutterアーキテクチャのベストプラクティス|公式ガイドのレイヤー設計・ディレクトリ構成・設計パターン【Flutter 3.47対応】
- Flutter 3.47とは?material_ui移行・iOS 15と3.47.7までの修正【2026年10月時点】