setState & the Lifecycle
setState ทำอะไรกันแน่
หัวข้อที่มีชื่อว่า “setState ทำอะไรกันแน่”setState ไม่ได้ “วาดหน้าจอใหม่” ทำสิ่งเล็ก ๆ อย่างเดียว: รัน callback ของคุณ (ที่ mutate field) แล้ว mark element ของ widget นี้เป็น dirty พอถึง frame ถัดไป Flutter จะ rebuild ทุก element ที่ dirty โดยเรียก build อีกครั้ง
class Counter extends StatefulWidget { const Counter({super.key}); @override State<Counter> createState() => _CounterState();}
class _CounterState extends State<Counter> { int _count = 0;
void _increment() { setState(() { _count++; // mutate state INSIDE the callback }); // then Flutter marks this element dirty }
@override Widget build(BuildContext context) { return TextButton( onPressed: _increment, child: Text('count: $_count'), ); }}มีกฎสองข้อที่ตามมา: mutate state ข้างใน callback ของ setState (เพื่อให้การเปลี่ยนและการ mark dirty เกิดพร้อมกัน) และรู้ไว้ว่า setState schedule rebuild สำหรับ frame ถัดไป — ไม่ใช่ synchronous
State lifecycle พิสูจน์ตามลำดับจริง
หัวข้อที่มีชื่อว่า “State lifecycle พิสูจน์ตามลำดับจริง”State object มี lifecycle ที่ชัดเจน รู้ว่า hook ไหนทำอะไรช่วยกัน bug ทั้งกลุ่มได้ — และลำดับไม่ใช่แค่เอกสาร มัน test ได้จริง
flowchart TB create["createState()"] --> init["initState() setup ครั้งเดียว"] init --> deps["didChangeDependencies() หลัง inherited deps พร้อม"] deps --> build["build() เรียกทุกครั้งที่ rebuild"] build --> update["didUpdateWidget() parent rebuild ด้วย config ใหม่"] update --> build build --> deactivate["deactivate() ถูกเอาออกจาก tree"] deactivate --> dispose["dispose() ไม่ถูกใส่กลับ — cleanup"]
import 'package:flutter/material.dart';
class LifecycleLogger extends StatefulWidget { const LifecycleLogger({super.key, required this.value, required this.log}); final int value; final List<String> log;
@override State<LifecycleLogger> createState() => _LifecycleLoggerState();}
class _LifecycleLoggerState extends State<LifecycleLogger> { @override void initState() { super.initState(); widget.log.add('initState'); }
@override void didChangeDependencies() { super.didChangeDependencies(); widget.log.add('didChangeDependencies'); }
@override void didUpdateWidget(covariant LifecycleLogger oldWidget) { super.didUpdateWidget(oldWidget); widget.log.add('didUpdateWidget'); }
@override void deactivate() { widget.log.add('deactivate'); super.deactivate(); }
@override void dispose() { widget.log.add('dispose'); super.dispose(); }
@override Widget build(BuildContext context) { widget.log.add('build'); return Text('${widget.value}'); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/lifecycle_logger.dart';
void main() { testWidgets( 'full State lifecycle order: initState, didChangeDependencies, build, didUpdateWidget, deactivate, dispose', (tester) async { final log = <String>[];
await tester.pumpWidget(MaterialApp(home: LifecycleLogger(value: 1, log: log))); expect(log, ['initState', 'didChangeDependencies', 'build']);
// Parent rebuilds with a new config for the same widget/State. await tester.pumpWidget(MaterialApp(home: LifecycleLogger(value: 2, log: log))); expect(log, ['initState', 'didChangeDependencies', 'build', 'didUpdateWidget', 'build']);
// Widget removed from the tree entirely. await tester.pumpWidget(const MaterialApp(home: SizedBox())); expect(log, [ 'initState', 'didChangeDependencies', 'build', 'didUpdateWidget', 'build', 'deactivate', 'dispose', ]); }, );}✓ full State lifecycle order: initState, didChangeDependencies, build, didUpdateWidget, deactivate, dispose
00:00 +1: All tests passed!initState— setup ครั้งเดียว: สร้าง controller, subscribe stream รันครั้งเดียว ตรงนี้ยังใช้contextกับ inherited widget อย่างเชื่อถือได้ไม่ได้ (พิสูจน์ข้างล่าง)didChangeDependencies— รันทันทีหลังinitStateและรันอีกทุกครั้งที่ inherited dependency ที่Stateนี้อ่านเปลี่ยนจริง ๆ เป็นที่ปลอดภัยสำหรับอ่าน data ของInheritedWidget/Provider/Theme.ofที่ setup ต้องพึ่งbuild— เรียกทุกครั้งที่ rebuild (พิสูจน์ข้างบน: สองครั้งสำหรับการเปลี่ยน config หนึ่งครั้ง — ครั้งแรกตอน mount ครั้งที่สองตอน update) ต้อง pure และเร็ว: ไม่มี side effect, ไม่มี network call, ไม่มี subscriptiondidUpdateWidget— parent rebuild แล้วส่ง widget config ใหม่ให้ State นี้; react ต่อ props ที่เปลี่ยน (เช่น re-subscribe ถ้าidเปลี่ยน) รันก่อนbuildที่มันเป็นตัว trigger ยืนยันจากลำดับ log ข้างบนdeactivate— element ถูกเอาออกจาก tree อาจถูกใส่กลับที่อื่นก่อน frame จะจบก็ได้ (เช่นย้ายไปใต้GlobalKey) — Flutter ยังไม่รู้ว่ามันถาวรdispose— เรียกเมื่อ Flutter ยืนยันแล้วว่า element ไม่ ถูกใส่กลับ: cleanup controller, cancel subscription ลืมตรงนี้คือ memory leak คลาสสิกของ Flutter
mounted: ป้องกัน setState หลัง await
หัวข้อที่มีชื่อว่า “mounted: ป้องกัน setState หลัง await”initState ที่ยิง async load เป็นเรื่องปกติ — แต่ State อาจถูก dispose (user navigate ออก, list item scroll ออกไปแล้ว rebuild) ขณะที่ Future นั้นยังค้างอยู่ พอมัน resolve แล้วเรียก setState ก็ไม่มี widget เหลือให้ rebuild
import 'package:flutter/material.dart';
/// Toggle: whether the widget guards its post-await setState with `mounted`.class DelayedLabel extends StatefulWidget { const DelayedLabel({super.key, required this.guarded}); final bool guarded;
@override State<DelayedLabel> createState() => _DelayedLabelState();}
class _DelayedLabelState extends State<DelayedLabel> { String _text = 'loading';
@override void initState() { super.initState(); _load(); }
Future<void> _load() async { await Future<void>.delayed(const Duration(milliseconds: 20)); // ❌ Without the guard: this widget may already be disposed (e.g. the // user navigated away before the delay finished) — setState throws. if (widget.guarded && !mounted) return; setState(() => _text = 'loaded'); }
@override Widget build(BuildContext context) => Text(_text);}แบบไม่ guard (guarded: false), unmount กลางทาง รันใน probe นี้ — output จริงไม่แก้ไขของ flutter test ที่ fail:
setState() called after dispose(): _DelayedLabelState#27577(lifecycle state: defunct, not mounted)This error happens if you call setState() on a State object for a widget that no longer appears inthe widget tree (e.g., whose parent widget no longer includes the widget in its build). This errorcan occur when code calls setState() from a timer, from an animation callback, or after anasynchronous operation (such as an awaited network request or other Future) completes after thewidget has been removed from the tree.The preferred solution is to cancel the timer or stop listening to the animation in the dispose()callback. Another solution is to check the "mounted" property of this object before callingsetState() to ensure the object is still in the tree.This error might indicate a memory leak if setState() is being called because another object isretaining a reference to this State object after it has been removed from the tree. To avoid memoryleaks, consider breaking the reference to this object during dispose().บรรทัด if (widget.guarded && !mounted) return; คือ fix — mounted (getter จริงบน State, เป็น true จนกว่า dispose() จะรัน) เปลี่ยน crash ให้กลายเป็น no-op ที่ไม่มีพิษภัย:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/mounted_after_await.dart';
void main() { testWidgets('mounted-guarded setState after dispose is a silent no-op (no crash)', (tester) async { await tester.pumpWidget(const MaterialApp(home: DelayedLabel(guarded: true))); // Unmount before the 20ms delay resolves. await tester.pumpWidget(const MaterialApp(home: SizedBox())); // Let the pending Future run past the mounted check. await tester.pump(const Duration(milliseconds: 30)); expect(tester.takeException(), isNull); });}✓ mounted-guarded setState after dispose is a silent no-op (no crash)
00:00 +1: All tests passed!setState ที่เรียกตอนกำลัง build อยู่แล้ว
หัวข้อที่มีชื่อว่า “setState ที่เรียกตอนกำลัง build อยู่แล้ว”กฎ “ห้ามเรียก setState ข้างใน build” มีเวอร์ชันที่แม่นยำกว่า: Flutter ยอม mark widget เป็น dirty ระหว่าง build pass ได้ก็ต่อเมื่อ widget นั้นเป็น descendant ของสิ่งที่กำลัง build อยู่ เพราะ parent build ก่อน children เสมอ ดังนั้น descendant ที่ dirty จะถูกเยี่ยมชมรอบนี้แน่นอน แต่ถ้าเอื้อมไป ด้านข้าง (sibling) หรือขึ้นบน Flutter ค้ำประกันแบบนั้นไม่ได้ และปฏิเสธ:
import 'package:flutter/material.dart';
class SiblingA extends StatefulWidget { const SiblingA({super.key}); @override State<SiblingA> createState() => SiblingAState();}
class SiblingAState extends State<SiblingA> { int count = 0; @override Widget build(BuildContext context) => Text('$count');}
/// Reaches sideways into a SIBLING's State and calls setState on it while/// THIS widget is still building — not a descendant relationship, so the/// framework cannot guarantee the sibling will still be visited this pass.class SiblingB extends StatelessWidget { const SiblingB({super.key, required this.aKey}); final GlobalKey<SiblingAState> aKey;
@override Widget build(BuildContext context) { // ❌ setState() on a sibling's State, called from this widget's build(). aKey.currentState?.setState(() { aKey.currentState!.count++; }); return const SizedBox(); }}
class BadCrossWidgetSetState extends StatelessWidget { BadCrossWidgetSetState({super.key}); final aKey = GlobalKey<SiblingAState>();
@override Widget build(BuildContext context) { return Column(children: [SiblingA(key: aKey), SiblingB(aKey: aKey)]); }}flutter analyze ตี smell ตัวนี้ให้เห็นก่อนคุณจะรันด้วยซ้ำ — setState เป็น @protected แปลว่ามีแค่ SiblingAState เองเท่านั้นที่ควรเรียกมัน:
warning • The member 'setState' can only be used within instance members of subclasses of 'State' • invalid_use_of_protected_memberรันจริงดู (MaterialApp(home: Scaffold(body: BadCrossWidgetSetState()))) output จริงไม่แก้ไขของ flutter test:
The following assertion was thrown building SiblingB(dirty):setState() or markNeedsBuild() called during build.This SiblingA widget cannot be marked as needing to build because the framework is already in theprocess of building widgets. A widget can be marked as needing to be built during the build phaseonly if one of its ancestors is currently building. This exception is allowed because the frameworkbuilds parent widgets before children, which means a dirty descendant will always be built.Otherwise, the framework might not visit this widget during this build phase.The widget on which setState() or markNeedsBuild() was called was: SiblingA-[LabeledGlobalKey<SiblingAState>#e3b3b]The widget which was currently being built when the offending call was made was: SiblingBนี่คือเหตุผลที่ setState ข้างใน onPressed callback ของตัวอย่างแรกสุดใช้ได้ปกติ (ไม่ใช่ข้างใน build): event handler รันระหว่าง frame ตอนที่ไม่มีอะไรกำลัง build อยู่ — ไม่มี _debugCurrentBuildTarget ให้ชนกัน
initState อ่าน inherited widget ไม่ได้อย่างเชื่อถือได้
หัวข้อที่มีชื่อว่า “initState อ่าน inherited widget ไม่ได้อย่างเชื่อถือได้”context.dependOnInheritedWidgetOfExactType<T>() (สิ่งที่ Theme.of, MediaQuery.of, Provider.of เรียกข้างใต้ทั้งหมด) ลงทะเบียน element ของ State นี้เป็น dependent เพื่อให้ rebuild อัตโนมัติเมื่อค่า inherited นั้นเปลี่ยน ระหว่าง initState element ยังต่อเข้ากับ dependency graph นั้นไม่ครบ:
import 'package:flutter/material.dart';
/// Calls dependOnInheritedWidgetOfExactType from initState() — too early.class BadInitStateInherited extends StatefulWidget { const BadInitStateInherited({super.key}); @override State<BadInitStateInherited> createState() => _BadInitStateInheritedState();}
class _BadInitStateInheritedState extends State<BadInitStateInherited> { @override void initState() { super.initState(); // ❌ context is not fully hooked into the inherited-widget dependency // tree yet inside initState(). context.dependOnInheritedWidgetOfExactType<MediaQuery>(); }
@override Widget build(BuildContext context) => const SizedBox();}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/initstate_inherited.dart';
void main() { testWidgets('dependOnInheritedWidgetOfExactType in initState throws', (tester) async { await tester.pumpWidget(const MaterialApp(home: BadInitStateInherited())); final exception = tester.takeException(); expect(exception, isA<FlutterError>()); expect( exception.toString(), contains('dependOnInheritedWidgetOfExactType<MediaQuery>() or dependOnInheritedElement() was called before'), ); });}✓ dependOnInheritedWidgetOfExactType in initState throws
00:00 +1: All tests passed!ข้อความ assertion จริง capture มาเต็ม ๆ:
dependOnInheritedWidgetOfExactType<MediaQuery>() or dependOnInheritedElement() was called before_BadInitStateInheritedState.initState() completed.When an inherited widget changes, for example if the value of Theme.of() changes, its dependentwidgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructoror an initState() method, then the rebuilt dependent widget will not reflect the changes in theinherited widget.Typically references to inherited widgets should occur in widget build() methods. Alternatively,initialization based on inherited widgets can be placed in the didChangeDependencies method, whichis called after initState and whenever the dependencies change thereafter.วิธีแก้คือตรงตามที่ข้อความบอก: ย้ายการอ่านไปที่ build() หรือไปที่ didChangeDependencies() ถ้าเป็น setup ครั้งเดียวที่ต้องพึ่งค่า inherited จริง ๆ (hook นั้นรันครั้งเดียวทันทีหลัง initState ซึ่งตอนนั้นการต่อ dependency พร้อมแล้ว — พิสูจน์ในตาราง lifecycle-order test ข้างบน ที่ didChangeDependencies log ทันทีหลัง initState)
field late กับ initState
หัวข้อที่มีชื่อว่า “field late กับ initState”field late final คือคำสัญญา: “ฉันจะ assign ตัวนี้ก่อนที่จะมีอะไรอ่านมัน” initState คือที่ที่เหมาะสมสำหรับรักษาสัญญานั้นสำหรับ resource ต่อ instance (TextEditingController, AnimationController, FocusNode) ที่ต้องใช้ widget หรือ this ในการสร้าง — field initializer ธรรมดารันก่อน constructor body และยังเข้าถึง widget ไม่ได้
import 'package:flutter/material.dart';
/// A `late` field is a promise: "I will assign this before anything reads/// it." initState() is the idiomatic place to keep that promise for/// per-instance resources (controllers, focus nodes) that need `this` or/// `widget` to construct — a plain field initializer runs too early for/// that (see the lesson prose).class ProfileField extends StatefulWidget { const ProfileField({super.key, required this.userId, this.skipInit = false}); final String userId; final bool skipInit; // forces the broken path, for the proof below
@override State<ProfileField> createState() => _ProfileFieldState();}
class _ProfileFieldState extends State<ProfileField> { late final TextEditingController _controller;
@override void initState() { super.initState(); if (!widget.skipInit) { _controller = TextEditingController(text: 'user-${widget.userId}'); } // ❌ if skipInit is true, `_controller` is never assigned here. }
@override void dispose() { if (!widget.skipInit) { _controller.dispose(); } super.dispose(); }
@override Widget build(BuildContext context) => TextField(controller: _controller);}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/late_initstate.dart';
void main() { testWidgets('late field assigned in initState is ready by first build', (tester) async { await tester.pumpWidget( const MaterialApp(home: Scaffold(body: ProfileField(userId: '42'))), ); final controller = tester.widget<TextField>(find.byType(TextField)).controller!; expect(controller.text, 'user-42'); });
testWidgets('a late field never assigned throws LateInitializationError on first read', (tester) async { await tester.pumpWidget( const MaterialApp(home: Scaffold(body: ProfileField(userId: '42', skipInit: true))), ); final exception = tester.takeException(); expect(exception, isA<Error>()); expect(exception.toString(), contains('LateInitializationError')); expect(exception.toString(), contains('has not been initialized')); });}✓ late field assigned in initState is ready by first build✓ a late field never assigned throws LateInitializationError on first read
00:00 +2: All tests passed!ข้อความจริง (ชื่อ field มี hash suffix เฉพาะรอบรัน): LateInitializationError: Field '_controller@23181088' has not been initialized. — เป็น LateError throw ตอนที่ build() อ่าน _controller เป็นครั้งแรก ไม่ใช่ตอนที่เรียก initState (branch ที่ถูกข้ามไม่ throw; การอ่านทีหลัง ต่างหากที่ throw) นี่คือเหตุผลที่ fix แบบ “เห็นได้ชัด” คือห่อการสร้างไว้ใน if โดยไม่มี else เป็นกับดัก: compile ผ่านสะอาด (analyzer เชื่อคำสัญญา late ของคุณ) และพังก็ต่อเมื่อ branch นั้นถูกเรียกจริงตอน runtime เท่านั้น
scope ของ rebuild
หัวข้อที่มีชื่อว่า “scope ของ rebuild”setState mark element ของ State นี้ เป็น dirty ดังนั้น build รันใหม่และผลิต subtree ใหม่ — แต่ Flutter ฉลาดเรื่องนี้ จะ diff widget subtree ใหม่กับ element tree เดิมและแตะเฉพาะส่วนที่เปลี่ยน ถึงอย่างนั้น build method ทั้งตัวก็รันอยู่ดี ดังนั้น setState ที่อยู่สูงใน tree แล้ว rebuild subtree ใหญ่ ๆ คือปัญหา performance จริง
บทเรียนเชิงปฏิบัติ: ดัน setState ให้ต่ำใน tree มากที่สุด แยกส่วนที่เปลี่ยนออกไปเป็น StatefulWidget เล็ก ๆ ของตัวเอง เพื่อให้เฉพาะ subtree เล็ก ๆ นั้น rebuild ไม่ใช่ทั้งหน้า (โมดูล performance เจาะลึกเรื่องนี้)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”State object คือ state machine เล็ก ๆ และทุก hook ตอบคำถาม “อะไรเปลี่ยนถึงมาถึงจุดนี้?” initState: ฉันเพิ่งถูกสร้าง — setup resource ที่มีอายุเท่ากับฉัน didChangeDependencies: InheritedWidget ที่ฉันพึ่งเพิ่งอ่านได้ (หรือเปลี่ยน) — react กับมัน ห้ามใน initState ที่การต่อยังไม่เสร็จ build: มีใครถามว่าฉันหน้าตายังไงตอนนี้ — ตอบให้ตรงและเร็ว ไม่มี side effect didUpdateWidget: parent ให้ฉันมีชีวิตต่อแต่ให้ config ใหม่ — ปรับ ไม่ใช่สร้างใหม่ deactivate/dispose: ฉันกำลังจะไป — deactivate คือ “อาจจะชั่วคราว” dispose คือ “จริงจัง เก็บกวาด” mounted แค่ถามว่า “dispose รันหรือยัง?” — คำถามเดียวที่ async callback ทุกตัวต้องถามก่อนแตะ setState
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
guard setState แบบ async ด้วย mounted | ไม่ crash ถ้า widget หายไปก่อน Future resolve | เพิ่มบรรทัดในทุก async callback — ลืมง่าย |
อ่าน inherited data ใน build() | ต่อถูกต้องเสมอ ทันสมัยเสมอ | รันทุก rebuild — ต้องให้การอ่านเองถูกๆ |
อ่าน inherited data ใน didChangeDependencies() แทน | รันครั้งเดียวต่อการเปลี่ยนจริง ไม่ใช่ทุก build | เหมาะแค่ setup ครั้งเดียวที่ขับเคลื่อนด้วยค่านั้น ไม่ใช่ค่าที่ใช้ตรง ๆ ใน UI |
late final สำหรับ field ที่พึ่ง widget | compile สะอาด ไม่ต้อง nullable-แล้ว-bang ทุกที่ | assignment path ที่ลืมกลายเป็น LateInitializationError ตอน runtime ไม่ใช่ compile error |
แยก setState ไว้ใน child StatefulWidget เล็ก ๆ | scope การ rebuild เล็กลง | ต้องดูแล widget/file เพิ่มอีกหนึ่งตัว |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “setState() repaint หน้าจอทันที” มัน mutate field และ mark element dirty — การ rebuild จริงเกิดในเฟรมถัดไป ไม่ใช่ synchronous ข้างในการเรียก
- “เรียก setState() ตัวไหนก็ปลอดภัยตราบใด compile ผ่าน” พิสูจน์ข้างบน:
setStateแบบไม่ guard หลังdispose()compile ผ่านสะอาดแต่ crash ตอน runtime; เรียกsetStateของ widget อื่นจากbuild()ของตัวเอง compile ผ่าน (พร้อม analyzer warning เพราะsetStateเป็น@protected) และก็ crash ตอน runtime เหมือนกัน - “initState เป็นที่ปลอดภัยสำหรับอ่าน Theme.of/MediaQuery.of/Provider.of” พิสูจน์ข้างบน: error จริงบอกตรง ๆ ว่าต้องย้ายไป
build()หรือdidChangeDependencies() - “field
lateก็เหมือนT?กับ!นั่นแหละ” fieldlateที่ไม่เคยถูก assign จะ throwLateInitializationErrorเฉพาะเจาะจงตอนที่มันถูก อ่าน เป็นครั้งแรก ไม่ใช่ตอนที่ประกาศหรือ skip — พิสูจน์ข้างบนแล้ว และจุดที่พังอาจอยู่ไกลจาก bug จริง (เงื่อนไขในinitState) - “deactivate() กับ dispose() คือ hook เดียวกันแค่คนละชื่อ”
deactivateยิงเมื่อ element ออกจาก tree และอาจกลับมาได้ (เช่นย้ายไปใต้GlobalKey) ใน frame เดียวกัน;disposeยิงก็ต่อเมื่อ Flutter ยืนยันแล้วว่าไปถาวร — พิสูจน์ตามลำดับใน lifecycle test ข้างบน
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”StatefulWidget ที่ AI generate มักได้ รูปทรง ของ lifecycle ถูกต้อง แต่ข้าม guard ที่สำคัญเฉพาะตอนมี race condition — ซึ่งเป็น bug ประเภทที่ผ่าน manual test เร็ว ๆ ไปได้แล้ว ship ออกไป
// @expect-error missing `mounted` guard — real "setState() called after dispose()" crash, proven aboveclass UserBio extends StatefulWidget { const UserBio({super.key, required this.userId}); final String userId; @override State<UserBio> createState() => _UserBioState();}
class _UserBioState extends State<UserBio> { String _bio = 'Loading...';
@override void initState() { super.initState(); fetchBio(widget.userId).then((bio) { setState(() => _bio = bio); // no mounted check }); }
@override Widget build(BuildContext context) => Text(_bio);}ก่อนเชื่อ StatefulWidget ที่ generate มา เช็คสิ่งเหล่านี้:
- ทุก
setStateที่เข้าถึงได้จาก async callback (.then,awaitcontinuation,Timer,Streamlistener) ถูก guard ด้วยif (!mounted) return;— หรือ callback นั้นถูก cancel ในdispose() context.dependOnInheritedWidgetOfExactType/Theme.of/MediaQuery.of/Provider.ofอยู่ในbuild()หรือdidChangeDependencies()ไม่ใช่initState()- ทุก assignment path ของ field
lateรันจริงก่อนที่ field จะถูกอ่านครั้งแรก —ifที่ไม่มีelseรอบการ assignlateในinitStateคือกับดักจริงที่พลาดง่าย (พิสูจน์ข้างบน) - ไม่เคยเรียก
setStateบนStateobject ของ widget อื่นจากข้างในbuild()ของ widget อีกตัว — เป็นการละเมิด@protectedจริงที่ analyzer จะตี บวกกับ assertion ตอน runtime
🐛 หา bug ในโค้ดนี้
class SearchResults extends StatefulWidget {const SearchResults({super.key, required this.query});final String query;@overrideState<SearchResults> createState() => _SearchResultsState();}
class _SearchResultsState extends State<SearchResults> {late List<String> _results;
@overridevoid initState() { super.initState(); if (widget.query.isNotEmpty) { _results = _search(widget.query); }}
@overrideWidget build(BuildContext context) { return Text('${_results.length} results');}}เฉลย
_results เป็น late แต่ถูก assign เฉพาะตอน widget.query.isNotEmpty — สร้าง widget นี้ด้วย query ว่างแล้ว build() จะ throw LateInitializationError: Field '_results' has not been initialized. ทันทีที่อ่าน _results.length ตรงกับกับดักที่พิสูจน์ไว้ก่อนหน้าในบทนี้เป๊ะ วิธีแก้: ให้ _results มี default จริง (late List<String> _results = []; หรือ assign _results = const []; ใน branch else) เพื่อให้ทุก path ผ่าน initState รักษาสัญญาไว้