StatelessWidgetとStatefulWidgetの違いと使い分け|Flutter 3.44の実装で確認する判定基準
Flutterでウィジェットを1つ書くたびに、StatelessWidgetとStatefulWidgetのどちらを継承するかを決めることになります。判断を外すと、入力した値が再ビルドのたびに消えたり、逆に変化しない部分まで再ビルドのたびに作り直したりします。この記事では、Flutter 3.44.9(Dart 3.12.2、2026年8月6日UTCリリース)のフレームワーク実装と、手元で実行した計測結果をもとに、両者の判定基準・constが効く仕組み・setStateの実際の挙動・Stateのライフサイクル・状態管理へ移すべき境界を順に整理します。
まとめ
判断材料は1つだけです。そのウィジェット自身が、再ビルドをまたいで覚えておかなければならない値を持つか。持つならStatefulWidget、持たないならStatelessWidgetです。色やラベルのように親から渡されるだけの値は「覚えておく値」ではないので、StatelessWidgetで足ります。
実装面で押さえるのは次の4点。1つ目はconstの効果です。手元のFlutter 3.44.9でウィジェットテストを実行したところ、親を10回setStateしてもconst付きの子はbuildが1回のまま、constを外した子は11回呼ばれました。2つ目、setStateは画面を即座に描き直しません。Elementに再ビルド予約を入れるだけで、実際のbuildは次のフレームです。3つ目はライフサイクルの順序。マウント時がinitState→didChangeDependencies→build、親が同じ型・同じkeyで作り直したときはdidUpdateWidget→buildになります。そして4つ目。状態が画面をまたいだ時点で、StatefulWidgetでは抱えきれません。
以下、それぞれの根拠をフレームワークのソースと実測で示します。
StatelessWidgetとStatefulWidgetの判定基準
状態の寿命で決まる継承先の選択
両者の差は「内部に可変の状態を置ける場所があるかどうか」の一点に集約されます。StatelessWidgetが持つのはbuildだけで、フィールドはfinal固定です。一方のStatefulWidgetは自分自身とは別にStateオブジェクトを持ち、ウィジェットが作り直されてもStateは同じインスタンスが生き続けます(同じ型・同じkeyで作り直された場合。判定の中身は次章で扱います)。だから値が残ります。
ここで1つ、名前から生まれやすい誤解を先に潰しておきます。Statelessは「表示が変わらない」という意味ではありません。自分では変更のきっかけを持たない、という意味です。親が新しい値で作り直せば、StatelessWidgetの表示は当然変わります。Widgetクラスには@immutableが付いているため、StatelessWidgetにfinalでないフィールドを置くとmust_be_immutableの指摘が出ます。変わらないのは表示ではなく、ウィジェットのインスタンスそのものです。
| 比較軸 | StatelessWidget | StatefulWidget |
|---|---|---|
| 再ビルドをまたぐ内部状態 | 持てない | Stateが保持 |
| 必須のオーバーライド | build | createState |
| 更新の起点 | 親の再ビルド+依存InheritedWidgetの変化 | 左記+setState |
| constコンストラクタ | 可 | 可(Stateは対象外) |
| 破棄時の後始末 | 不要 | disposeが必要 |
「更新の起点」の行は補足が要ります。StatelessWidgetでも、Theme.of(context)のようにInheritedWidgetへ依存すると、依存先が変わった時点で再ビルドされます。Element.didChangeDependenciesがmarkNeedsBuildを呼ぶ実装になっており(framework.dart:5190-5194)、StatelessElementはこれを上書きしていないためです。手元でconst付きのまま子を固定してThemeのdataだけ差し替えたところ、子のbuild回数は1回から2回に増えました。Statelessでも外部要因で再ビルドは起きます。
表の最終行が実務では効いてきます。TimerやStreamSubscription、AnimationControllerのように解放が要るものを持った時点で、StatefulWidgetを選びdisposeで必ず止める必要が出ます。逆に言えば、解放するものも覚えておく値も無いなら、StatefulWidgetにする理由はありません。
迷ったらStatelessWidgetから始めるべき理由
結論から言えば、判断がつかない段階ではStatelessWidgetで書き始めるのが正解です。理由は後戻りのコストが非対称だからです。StatelessWidgetからStatefulWidgetへの変更は、クラスを2つに割って可変フィールドをState側へ移すだけで、外部から見た使い方は変わりません。逆向き、つまりStatefulWidgetとして書いてしまったものをStatelessWidgetへ戻す作業では、setStateの呼び出し箇所を全部たどって「本当に自前で持つべき状態だったのか」を1つずつ判定し直すことになります。
「将来状態を持ちそうだから、とりあえずStatefulWidgetにしておく」という書き方は採用しないでください。constが付けられなくなるため、次章で示す再ビルドのスキップが効かなくなります。使っていないStateクラスは、コストだけを払って何も返しません。
StatelessWidgetの最小実装とconstが再ビルドを止める仕組み
公式テンプレートに合わせた書き方とsuper.key
StatelessWidgetの実装はbuildのオーバーライド1つで完結します。flutter createが生成するテンプレート(Flutter 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');
}
}
super.keyは省略できますが、省略するとflutter_lintsが有効な環境でuse_key_in_widget_constructorsの指摘が出ます。公開ウィジェットのコンストラクタにはkeyを受け取らせる、というのがFlutter公式のlintセットの既定方針です。
constの子ウィジェットでbuildが1回に固定される実測
constを付ける効果は、フレームワークのソースを追うとはっきりします。Element.updateChildは、新旧のウィジェットがchild.widget == newWidgetを満たす場合、その子ツリーの更新処理を丸ごと飛ばします(framework.dart:4014)。ここで使われる==はWidgetクラスで@nonVirtualが付きObjectの同一性判定に委譲されているため(framework.dart:364-366)、比較しているのは実質「同じインスタンスかどうか」です。オーバーライドして値比較に変えることはできません。
Dartのconst式は同じ内容なら同一インスタンスに正規化されます。つまりconst GreetingLabel(name: 'a')と書いた箇所は、親が何度buildされても毎回同じインスタンスを返し、条件が成立して子ツリーがスキップされます。
実際に測ってみます。buildの呼び出し回数を数える子ウィジェットを2つ用意し、片方だけconstを付けて親から10回setStateを呼びました。Flutter 3.44.9 のflutter testで得られた出力が次のとおりです。
RESULT constChildBuilds=1 plainChildBuilds=11
const付きは初回の1回だけ、constなしは初回+10回の合計11回です。リストの行やアイコン、固定文言のように内容が変わらない部分にconstを付けるだけで、その部分は再ビルド時にbuildが呼ばれなくなります。
ここは解析ツールが助けてくれない領域なので、自分で付ける習慣が要ります。flutter_lintsに含まれるprefer_const_constructors_in_immutablesは、コンストラクタの宣言側をconstにさせるルールで、呼び出し側のconst付け忘れは見ません。呼び出し側を促すprefer_const_constructorsはflutter_lintsにもpackage:lints/recommended.yamlにも含まれていません。実際、宣言にconstを持つウィジェットをconstなしで呼ぶコードを解析してもNo issues found!でした。上の計測で11回ビルドされた側も、宣言はすでにconst付きです。
なお、条件が成立しなかった場合はWidget.canUpdateの判定に進みます。中身はruntimeTypeの一致とkeyの一致だけです(framework.dart:382-384)。両方一致すればElementとStateを再利用し、どちらか外れれば古いElementを捨てて作り直します。リストの要素を並べ替えたときに入力値やチェック状態が別の行へ付いていく不具合は、このkeyの指定漏れで起こります。
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に引っかかります(package:lints/recommended.yamlに含まれ、flutter_lints経由で有効になります)。公開APIであるcreateStateの戻り値にプライベート型を出さないよう、State<CounterPanel>と書くのが公式テンプレートの書き方です。
State側から親のパラメータを読むときはwidget.stepのようにwidgetプロパティを経由します。コンストラクタ引数をStateのフィールドへコピーしないでください。親が新しい値で作り直しても、コピーした値は古いまま残ります。
setStateは即時再描画ではなく再ビルドの予約
setStateの実装は驚くほど短く、渡されたコールバックを同期実行したあと_element!.markNeedsBuild()を呼ぶだけです(framework.dart:1219)。markNeedsBuildはElementをdirtyとして登録するだけで、その場でbuildは走りません。実際の再ビルドは次のフレームです。
これも計測できます。build回数を数えるウィジェットで、setStateを呼んだ直後と、1フレーム進めた後の回数を比べました。
RESULT_TIMING before=2 afterSetState=2 afterPump=3
before=2という初期値は、この時点で既にマウント時と親からの更新時に2回ビルドされているためです。setStateを呼んだ時点では2回のまま変わらず、フレームが進んで初めて3回目のbuildが走っています。
ここから2つの実務上の帰結が出ます。1つ目は、値の書き換え自体はsetStateのコールバックの外で行っても動作するという点です。setStateが見ているのはコールバックの中身ではなく「呼ばれたかどうか」だけだからです。ただし可読性の面で、変更する値はコールバック内にまとめてください。2つ目は、setStateを連続で何度呼んでも再ビルドが1回にまとまる点です。同じフレーム内で3回呼んでから1フレーム進めた計測がこちらです。
RESULT_BATCH base=1 after3setStateAnd1pump=2
3回呼んでもbuildの実行回数は1しか増えていません。イベントハンドラの中で条件ごとにsetStateを分けて書いても、再ビルドは1回にまとまるため描画コストは増えません。
もう1つ、markNeedsBuildはElementがactiveでない場合に何もせず戻ります(framework.dart:5339-5343)。ここでいうactiveでない状態とは、ツリーから一時的に外れた(deactivate済みで、まだunmountされていない)Elementのことです。この段階のsetStateは無視されるだけで何も起きません。完全に破棄されたあとのsetStateは扱いが変わります。次章のエラーになります。
Stateのライフサイクルと各コールバックが呼ばれる条件
マウント時と親再ビルド時の呼び出し順の実測
ライフサイクルの各メソッドをログに記録するウィジェットを用意し、マウント・親からの更新・破棄の3局面で呼び出し順を記録しました。Flutter 3.44.9 での結果です。
RESULT_MOUNT initState > didChangeDependencies > build
RESULT_UPDATE didUpdateWidget(a->b) > build
RESULT_UNMOUNT dispose
初回マウントではinitStateのあとにdidChangeDependenciesが必ず1回入ります。公式ドキュメントも「このメソッドはinitStateの直後にも呼ばれる」と明記しています(framework.dart:1469-1470)。InheritedWidgetに依存する初期化、たとえばTheme.of(context)やMediaQuery.of(context)を使う処理はinitStateではなくここに置きます。
親が同じ型・同じkeyで作り直したときはdidUpdateWidgetが呼ばれ、その引数として古いウィジェットが渡されます。initStateは呼ばれません。Stateが使い回されているためです。ここで注意したいのは、didUpdateWidgetの中でsetStateを呼ぶ必要がないことです。フレームワークがdidUpdateWidgetのあとに必ずbuildを呼ぶため、このsetStateは冗長だとソースのコメントが明言しています(framework.dart:1024-1025)。購読先の切り替えのように、古いウィジェットの値と比べて処理を分ける用途だけに使ってください。
disposeが呼ばれない場面と後始末の書き方
disposeはウィジェットがツリーから外れたときに呼ばれますが、アプリの終了時には呼ばれません。フレームワークのドキュメントは「開発者が期待するタイミングで呼ばれるとは限らない」と述べたうえで、バッテリーの発火、端末の水没、メモリ逼迫によるOSのプロセス強制終了を例に挙げています(framework.dart:1304-1314)。永続化が必要なデータをdisposeで書き出す設計は成立しません。アプリのライフサイクルを見るならAppLifecycleListenerを使います。
後始末の書き方は次の形が基本です。initStateで購読を開始し、disposeで解除します。どちらも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');
}
}
if (!mounted) return;を挟んでいるのは、購読解除が間に合わない経路が残るためです。mountedはStateが破棄されるとfalseになります(手元でも破棄前後でtrueからfalseへ変わることを確認しました)。
後始末を怠ったときに何が起きるかも実際に確認しました。Timer.periodicをinitStateで開始し、disposeで止めないままウィジェットをツリーから外してsetStateを呼ぶと、次のエラーが出ます。
setState() called after dispose(): LeakyState#20bd3(lifecycle state: defunct, not mounted)
この分かりやすいメッセージが出るのはデバッグビルドだけです。判定がassertの中に書かれているためです(framework.dart:1161-1185)。ただしリリースビルドで無害になるわけではありません。Elementのunmount時にstate._element = nullが実行されるため(framework.dart:6043)、ソース上は、assertが無効な状態では_element!.markNeedsBuild()の位置(framework.dart:1219)でnullチェックエラーになる読みです(ここだけは実行での確認ではなく実装からの読み取りです)。initStateで購読・生成したものはdisposeで必ず解放し、非同期処理からsetStateを呼ぶ経路ではmountedを確認してください。破棄後のmountedがfalseになることは手元でも確認しています。
flutter analyzeが指摘する典型的な3つのlint違反
ウィジェットの入門記事に載っているコードは、そのまま貼るとflutter analyzeの指摘(severityはinfo)が出るものが少なくありません。実際に、旧版のこの記事に載っていた2つのサンプル(keyなしのStatelessWidgetと、_CounterWidgetState createState()と書いたStatefulWidget)を、flutter_lintsを有効にしたFlutter 3.44.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 2.5s)
この記事の前半で示した書き方に直したファイルはNo issues found!になりました。解析結果を変えたのは、super.keyの追加(2件)と、createStateの戻り値型をState<CounterPanel>にしたこと(1件)です。const付きコンストラクタは解析結果には現れませんが、前章の再ビルドスキップに効きます。ここは役割が別なので分けて覚えてください。
3件の内訳はルール2種類です。3つ目として押さえておきたいのがno_logic_in_create_stateで、これもflutter_lintsに含まれます。createStateの中でStateにコンストラクタ引数を渡したり条件分岐を書いたりする実装を禁止するものです。createStateはフレームワークがElementを作るタイミングで呼ばれ、いつ何回呼ばれるかを開発者側が制御できません。初期化はStateのinitStateに置いてください。flutter analyzeはinfoだけでも終了コード1を返すため、CIに組み込んでおけばこの3種類は書いた時点で止められます。
StatefulWidgetで抱えきれなくなる境界と状態管理への移行
前提として、状態を外へ出す手段はライブラリだけではありません。判定基準の章で触れたInheritedWidget(ThemeやMediaQueryの実装に使われている仕組み)を使えば、StatelessWidgetのままでも祖先の値の変化に追従できます。以下はそれを踏まえたうえでの話です。
setStateで足りるのは、状態を持つウィジェットと、それを表示するウィジェットが同じサブツリーに収まっている場合だけです。開閉状態を持つアコーディオン、フォーム1つ分の入力値、アニメーションの進行度。このあたりはsetStateのままで十分で、ライブラリを入れる必要はありません。
境界は明確です。同じ値を、親子関係にない2箇所以上のウィジェットが読む必要が出た時点で、StatefulWidgetでは破綻します。共通の祖先まで状態を持ち上げてコールバックを何段も引き回すことになり、その祖先がsetStateするたびに無関係なサブツリーまで再ビルド対象になるためです。ログイン中のユーザー情報やカート、ダークモードの設定のように画面をまたぐ値は、最初から外に出してください。
移行先は段階的に選べます。値が1つで購読側も限定的ならValueNotifierとValueListenableBuilderの組み合わせで足ります。ライブラリを入れるなら、Riverpodの特徴と他の状態管理ライブラリとの違いを押さえたうえで、NotifierProviderとStateNotifierProviderの使い分けを確認するのが早道です。画面単位で責務を切るところまで進めるなら、公式アーキテクチャガイドに沿ったViewModel設計が指針になります。
逆に、状態管理ライブラリを入れたからといってStatefulWidgetが不要になるわけではありません。AnimationControllerやTextEditingController、ScrollControllerのようにライフサイクルに紐づく破棄が必要なオブジェクトは、引き続きStateのinitStateとdisposeで管理するのが素直です。
よくある質問
StatelessWidgetとStatefulWidgetはどちらを使うべきですか?
そのウィジェット自身が再ビルドをまたいで値を覚える必要があるかどうかで決めます。必要が無ければStatelessWidgetです。判断がつかない段階でもStatelessWidgetから始めてください。手戻りが小さい向きだからです。
initStateとbuildはどちらが先に呼ばれますか?
initStateが先です。実測した初回マウント時の順序はinitState→didChangeDependencies→buildでした。Theme.of(context)のようにInheritedWidgetを読む初期化はinitStateではなくdidChangeDependenciesに書きます。
setStateを呼んでも画面が更新されないときは何を確認しますか?
まず、更新したい値がStateのフィールドにあるかを確認します。widgetプロパティ側の値は親が作り直さない限り変わりません。次に、返しているウィジェットをStateのフィールドにキャッシュしていないかを見ます。組み立て済みのインスタンスを使い回すとElement.updateChildの同一性判定でその子ツリーの更新が省略され、値だけ古いまま表示されます。手元で試したところ、キャッシュしたTextだけが初期値のまま残りました。buildが走るのが次のフレームである点も押さえてください。
非同期処理の完了後にsetStateを呼ぶにはどう書きますか?
awaitの直後にif (!mounted) return;を挟んでからsetStateを呼びます。通信やタイマーの完了までにウィジェットがツリーから外れていると、破棄済みのStateに対するsetStateになるためです。デバッグビルドではsetState() called after dispose()で気づけますが、判定はassertの中にあるので本番ビルドでは同じメッセージは出ません。BuildContextをawaitをまたいで使う場合はuse_build_context_synchronouslyの指摘も出ます。
リストを並べ替えると入力値が別の行に移ってしまうのはなぜですか?
keyを指定していないためです。Flutterがウィジェットの更新先を決めるWidget.canUpdateは、runtimeTypeとkeyの一致だけを見ます。keyを付けていないとkeyはどちらもnullで一致するため、並べ替えても同じ位置のElementとStateがそのまま再利用され、入力値やチェック状態が居残ります。各行にValueKeyなどデータ由来のkeyを与えれば、Flutterが行とStateの対応を保ったまま並べ替えます。