変化する状態は誰が持つのか
Widget が不変なら、変化する状態はどこに置くのか。StatelessWidget と StatefulWidget の使い分け、setState が再描画を起こす仕組み、State のライフサイクル、そしてローカル状態と共有状態の境界を整理する。
前回、Widget は不変の設計図だと確認しました。ここで当然の疑問が出ます。Widget が不変なら、 カウンターの数値やトグルの ON/OFF のように「変化する値」はどこに置くのか?
答えは、StatefulWidget に付属する State オブジェクトです。今回は、状態を持たない
StatelessWidget との使い分けから始めて、setState() が再描画を起こす仕組み、State の
ライフサイクル、そして「その状態はローカルか、共有か」の見極めまでを一本の線でつなぎます。
§ 01STATELESS状態を持たない Widget
StatelessWidget は、自分の中に変化する状態を持たない Widget です。表示に必要なものは
すべて、コンストラクタで受け取る final なフィールドと、context からたどれる祖先の値
(Theme など)でまかないます。
class Greeting extends StatelessWidget {const Greeting({super.key, required this.name});final String name;@overrideWidget build(BuildContext context) {return Text('こんにちは、$name さん');}}
この Widget は、親が再構築されて新しい Greeting が渡されたときや、依存する祖先(Theme など)
が変わったときに再構築されます。渡された name が前と同じでも、親が作り直せば build() は
再実行されえます(const で同一インスタンスが使い回された場合はスキップされます)。入力を
描くだけの部品は、まず StatelessWidget にします。迷ったら Stateless から始めるのが基本です。
§ 02STATEFUL変化する状態を持つ Widget
自分で変化する値を抱える必要が出たら、StatefulWidget を使います。特徴的なのは、2 つの
クラスに分かれることです。
class Counter extends StatefulWidget {const Counter({super.key});@overrideState<Counter> createState() => _CounterState();}class _CounterState extends State<Counter> {int _count = 0;@overrideWidget build(BuildContext context) {return TextButton(onPressed: () => setState(() => _count++),child: Text('タップ回数: $_count'),);}}
なぜ 2 クラスに分かれるのか。ここが StatefulWidget を理解する核心です。
前回のとおり Widget は不変で、再構築のたびに作り直されます。もし状態を Widget に持たせたら、
作り直すたびに初期値へ戻ってしまいます。そこで、変化する状態は Widget ではなく、再構築を
またいで生き残る State オブジェクトに置きます1生き残る先は、前回出てきた Element。State は Widget ではなく、ツリー上の位置に対応する Element に結び付いているため、Widget を作り直しても保持される。。Counter(Widget)は何度でも
作り直されますが、_CounterState は同じ位置にいる限り保持され、_count の値は失われません。
StatefulWidget(Counter)… 不変。設定を運ぶだけState(_CounterState)… 生き残る。変化する値とロジックを持つ
§ 03SETSTATE変更を Flutter に知らせる
_count を書き換えるとき、setState() で包んでいることに注目してください。
onPressed: () => setState(() => _count++),
State のフィールドをただ書き換えても、画面は変わりません。Flutter は「状態が変わった」ことを
知らないからです。setState() は、その State に対応する Element に**「汚れた(dirty)」と印を
付け、次のフレームで build() を呼び直す**よう予約します。これで新しい _count を反映した
Widget が作られ、画面が更新されます。
初学者がまずハマるのがここです。setState() を呼ばずにフィールドを書き換えると、値は変わって
いるのに画面が更新されない——これは Flutter でいちばん多い「動かない」の原因のひとつです。
慣習として、状態の変更は setState() のコールバックの中で行います2厳密には、値の書き換えが setState() の呼び出し前でも後でも再描画は起きる(効くのは「setState() を呼んだ」事実)。それでも変更をコールバック内に書くのは、「この変更が UI に影響する」と読み手に示す慣習であり、外部の非同期処理に対する目印にもなる。。
そして setState() は、そのウィジェット限定・一時的な UI 状態に使うものだと覚えてください。
アプリ全体で共有したい状態にまで setState() を使い始めると、状態の受け渡しが破綻します。
その境界はこの記事の最後で扱います。
§ 04LIFECYCLEState の一生
State には、生成から破棄までの節目に呼ばれるメソッドがあります。主なものを押さえれば十分です。
| メソッド | いつ | 主な用途 |
|---|---|---|
initState() | 生成時に一度 | コントローラ生成、購読の開始 |
didChangeDependencies() | initState() 直後、および依存する InheritedWidget が変わったとき | .of(context) に依存する初期化 |
didUpdateWidget() | 親が同じ位置へ、同じ型・key の新しい Widget を渡したとき | 新しい設定に State を追従させる |
dispose() | 破棄時に一度 | コントローラ破棄、購読・タイマーの解除 |
build() | 再構築のたび | UI を描く |
いちばん大事なのは、initState() と dispose() は対になっていることです。initState() で
始めたもの(タイマー、ストリーム購読、コントローラ)は、dispose() で必ず後始末します。
class _TickingClockState extends State<TickingClock> {Timer? _timer;Duration _elapsed = Duration.zero;@overridevoid initState() {super.initState();_timer = Timer.periodic(const Duration(seconds: 1), (_) {setState(() => _elapsed += const Duration(seconds: 1));});}@overridevoid dispose() {_timer?.cancel(); // ← これを忘れると破棄後も動き続けてリークするsuper.dispose();}@overrideWidget build(BuildContext context) => Text('${_elapsed.inSeconds} 秒');}
dispose() での後始末を忘れると、画面を離れてもタイマーやストリームが動き続け、破棄済みの
State を更新しようとしてエラーやメモリリークになります。Flutter の状態管理のバグの多くは、
この後始末漏れです。なお initState() の中では、祖先に依存する .of(context) の一部は
まだ安全に使えない点にも注意してください3initState() の時点では、InheritedWidget に依存する .of(context) はまだ安全に使えない。依存を伴う取得は didChangeDependencies() で行う。単発の初期化は initState() でよい。。
§ 05BOUNDARYローカル状態か、共有状態か
最後に、いちばん実践的な判断です。その状態は StatefulWidget で持つべきか、もっと外に出す
べきか。
setState()(ローカル状態)で十分なのは、状態がその画面の見た目だけに関係し、その Widget と
運命を共にする場合です。
- アコーディオンの開閉、タブの選択位置
- 入力欄の一時的な文字、スクロール位置
- 押した瞬間のアニメーション状態
一方、状態が複数の画面で共有される・Widget より長く生き残る・アプリのデータそのものである
場合は、StatefulWidget の外に出します。
- ログイン中のユーザー、買い物カゴの中身
- 設定値、同期したサーバーデータ
こうした共有状態は、Widget ツリーの外側に置いて配る仕組み——次回以降に扱う Provider / Riverpod ——の担当です。原則は 「状態はできるだけローカルに持ち、他が必要とするときだけ外へ持ち上げる」。 最初から何でも共有状態にすると、かえって追いにくくなります。
§ 06SUMMARY状態の置き場所
StatelessWidget… 入力を描くだけ。まずはこれからStatefulWidget… 不変の Widget と、再構築をまたいで生き残るStateの 2 クラス構成setState()…Stateの値を変えたら呼ぶ。呼ばないと画面は更新されない- ライフサイクル …
initState()とdispose()は対。後始末漏れがリークの温床 - 境界 … 一時的でローカルなら
setState()、共有・アプリデータなら外へ持ち上げる
「変化する状態を誰が持つか」を決められるようになったら、次の課題はその状態を複数の画面で
どう配るかです。次回は Riverpod に入り、watch / read / listen の違いから、状態を
ツリーの外で持って配る仕組みを読み解きます。
- [1] 生き残る先は、前回出てきた Element。
Stateは Widget ではなく、ツリー上の位置に対応する Element に結び付いているため、Widget を作り直しても保持される。 ↩ - [2] 厳密には、値の書き換えが
setState()の呼び出し前でも後でも再描画は起きる(効くのは「setState()を呼んだ」事実)。それでも変更をコールバック内に書くのは、「この変更が UI に影響する」と読み手に示す慣習であり、外部の非同期処理に対する目印にもなる。 ↩ - [3]
initState()の時点では、InheritedWidgetに依存する.of(context)はまだ安全に使えない。依存を伴う取得はdidChangeDependencies()で行う。単発の初期化はinitState()でよい。 ↩