ข้ามไปยังเนื้อหา

Choosing an Approach

ตอนนี้คุณรู้กลไกแล้ว — ไม่ใช่จากคำอธิบาย แต่จาก test จริงที่รันกับแต่ละตัวในโมดูลนี้: ลำดับ lifecycle ที่พิสูจน์ด้วย widget test, build count ที่นิ่งสำหรับ non-dependent, crash ที่ ProviderScope หายไปที่ capture มาตรง ๆ, non_exhaustive_switch_expression จาก analyzer การเลือกใช้ส่วนใหญ่เป็นเรื่องของ scope และความซับซ้อน และเดินตาม decision path สั้น ๆ:

flowchart TB
  q1{"มี widget มากกว่าหนึ่งตัว
ที่ต้องการ state นี้ไหม?"} -->|ไม่| ss["setState
(ephemeral / local)"]
  q1 -->|ใช่| q2{"ต้องการ cache ต่อ parameter,
rebuild แบบละเอียด,
หรือ async แบบ first-class ไหม?"}
  q2 -->|ใช่| rp["Riverpod
(compile-safe, test ได้, .select/.family)"]
  q2 -->|ไม่, app เล็กหรือ
ทีมรู้จักอยู่แล้ว| prov["Provider
(InheritedWidget + ChangeNotifier)"]
  q1 -->|ใช่ และ flow
ซับซ้อน/async หนัก| q3{"ต้องการ state transition
ที่เช็ค exhaustive, trace ได้,
unit-test ได้ไหม?"}
  q3 -->|ใช่| bloc["BLoC / Cubit
(sealed event, unidirectional)"]
  q3 -->|ไม่| prov
เลือกวิธีไหนดี
  • widget เดียวสน? setState อย่าเพิ่ม package เพื่อ checkbox — พิสูจน์ในบท setState & the Lifecycle: lifecycle ทั้งหมด (initState → didChangeDependencies → build → dispose) มีให้ฟรีอยู่แล้ว
  • หลาย widget share และคุณต้องการ cache ต่อ parameter, คุม rebuild ระดับ .select(), หรือ async state แบบ first-class? Riverpod — พิสูจน์ในบท Provider & Riverpodด้วย test disposal .autoDispose/.family จริงและ AsyncNotifier loading/data/error
  • หลาย widget share, app/ทีมเล็กกว่า และ state ทรง InheritedWidget พอแล้ว? Provider — กลไกเดียวกับ InheritedWidget ดิบ ๆ พิสูจน์ในบท InheritedWidgetแค่มี syntax สวยกว่า
  • ซับซ้อน, async หนัก, ต้องการ state transition ที่เช็ค exhaustive และ trace ได้? BLoC/Cubit — พิสูจน์ในบท BLoC & Patternsด้วย non_exhaustive_switch_expression จริงและ blocTest ที่ assert ลำดับ state เป๊ะ

ไม่มีรางวัลสำหรับการใช้เครื่องมือที่หนักที่สุด app จริงผสมทั้งสี่ได้อย่างสบาย: setState สำหรับ UI ชิ้นเล็ก, Riverpod provider สองสามตัวสำหรับ shared state, และ BLoC เฉพาะที่ความซับซ้อนสมควรจริง ๆ

วิธีrebuild scopetestabilityboilerplatecodegen dependencylearning curve
setStatebuild() ทั้งก้อนของ State นั้น — ดันให้ต่ำใน tree เพื่อจำกัดรัศมีwidget test ของ flutter_test เท่านั้น; ไม่มี logic layer ที่ unit-test ได้น้อยที่สุด — ไม่มี class เพิ่มไม่มีต่ำที่สุด — มันคือ widget lifecycle เอง
InheritedWidgetเฉพาะ element ที่เรียก dependOnInheritedWidgetOfExactType/InheritedModel.inheritFrom — พิสูจน์ด้วย build counter ที่นิ่งสำหรับ non-dependentwidget test; logic กับ UI พันกันถ้าไม่แยกเองเขียน of(context) + updateShouldNotify (+ updateShouldNotifyDependent สำหรับ InheritedModel) เองไม่มีปานกลาง — กลไกง่าย แต่ ceremony ต้องทำมือ
Providerเหมือน InheritedWidget (สร้างตรงบนมันเลย) — context.watch rebuild, context.read ไม่widget test; logic ของ ChangeNotifier unit-test แยกจาก widget ได้น้อย — ChangeNotifierProvider + subclass ของ ChangeNotifierไม่มีต่ำ — ทรง OOP ที่คุ้นเคย (class ที่เรียก notifyListeners())
Riverpod (เขียนมือ)ref.watch rebuild; .select() แคบลงเหลือ field ที่ derive มาตัวเดียว — พิสูจน์ว่าข้าม rebuild สำหรับการเปลี่ยน field ที่ไม่เกี่ยวข้องtest ProviderContainer โดยไม่มี widget เลย; ProviderScope(overrides:) พิสูจน์แล้วสำหรับ fake ระดับ widgetปานกลาง — NotifierProvider<X, Y>(X.new) ต่อ providerไม่มีปานกลาง — ศัพท์ใหม่ (ref, provider, container) แต่ไม่ต้องคิดเรื่อง BuildContext lookup
Riverpod (@riverpod codegen)เหมือนเขียนมือ; .autoDispose เป็น default — พิสูจน์ตรง ๆ จาก generated output isAutoDispose: trueเหมือนเขียนมือน้อยที่สุดในสามสไตล์ของ Riverpod — type และ boilerplate ถูก generatebuild_runner เข้ามาอยู่ใน loop — ไฟล์ .g.dart ที่ generate จริงที่ analyzer ของคุณตรวจ พิสูจน์ในโมดูลนี้ปานกลาง-สูง — ศัพท์ Riverpod เดิม บวกขั้นตอน codegen ที่ต้องเข้าใจ
BLoC/CubitbuildWhen ของ BlocBuilder/listenWhen ของ BlocListener — พิสูจน์ว่ากัน build counter ให้นิ่งตลอดสี่การเปลี่ยน state ที่ไม่เกี่ยวข้องสูงที่สุด — blocTest assert ลำดับ state ที่ emit เป๊ะโดยไม่มี widget เลย พิสูจน์ในโมดูลนี้สูงที่สุด — sealed event class, switch/on<T> handler, Equatable บนทุก state/eventไม่จำเป็น (Equatable/bloc_test คือ ergonomics ไม่ใช่ codegen)สูงที่สุด — ต้องมีวินัยการ model event/state เพิ่มเข้ามาบนทุกอย่าง

เมื่อ sibling widget สองตัวต้องการ state เดียวกัน ให้ย้าย state ขึ้นไปไว้ที่ common ancestor ที่ใกล้ที่สุดแล้วส่งลงมา — lifting state up นี่คือสิ่งแรกที่ควรลองก่อนหยิบ shared-state package ใด ๆ เพราะไม่ต้องใช้กลไกข้างบนเลยสักอัน

สถานการณ์state ควรอยู่ที่ไหน
widget เดียวใช้ใน widget นั้น (setState)
sibling สองตัว sharelift ไปที่ parent ร่วม
หลาย widget ทั่ว treestore ที่ share (Provider/Riverpod)
ทั้ง app, ซับซ้อน, asynclayer BLoC/Cubit

นิสัยที่สำคัญที่สุดคือการจัดหมวด state ให้ถูก — ทำผิดแล้วคุณจะ over-engineer checkbox หรือทำ data ที่ user คาดว่าจะอยู่ข้ามหน้าหายไป

Ephemeral state — อยู่ใน State เดียว หายไปตอน rebuild/hot restart ก็ไม่เป็นไร ไม่มี widget อื่นต้องการมัน:

lib/ephemeral_example.dart
import 'package:flutter/material.dart';
/// Ephemeral: a PageView's current index only this widget (and its direct
/// children) care about — fine to lose on hot restart, fine to hold with
/// plain setState.
class OnboardingPager extends StatefulWidget {
const OnboardingPager({super.key});
@override
State<OnboardingPager> createState() => _OnboardingPagerState();
}
class _OnboardingPagerState extends State<OnboardingPager> {
int _page = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Page $_page of 3'),
TextButton(
onPressed: () => setState(() => _page = (_page + 1) % 3),
child: const Text('Next'),
),
],
);
}
}

ตัวอย่าง ephemeral อื่น ๆ: ข้อความปัจจุบันของ TextEditingController ก่อน submit, Card กางอยู่หรือไม่, ค่าปัจจุบันของ AnimationController, error ของ validation ของ form field ก่อน submit รอบถัดไป

App state — share ข้ามหน้า มักถูก persist มีอายุนานกว่า widget ตัวไหนตัวหนึ่ง:

  • Auth: user login อยู่ไหมและเป็นใคร (root router, หน้า profile และ API client ต้องใช้พร้อมกัน)
  • เนื้อหา cart ใน shopping app (badge บน app bar, หน้า cart, checkout ต้องใช้)
  • Cached server data (list ของสินค้าที่ fetch มาครั้งเดียว อ่านจากหลายหน้าโดยไม่ fetch ซ้ำ)
  • settings ทั้ง app (theme, locale) ที่ root MaterialApp อ่านและเปลี่ยนจากหน้า settings ที่ navigate ไปสามชั้น

App state ควรอยู่ใน Riverpod/Provider (หรือ BLoC สำหรับเคสที่ซับซ้อน async หนัก) — พิสูจน์ตลอดโมดูลนี้ว่ารอด widget rebuild เข้าถึงได้โดยไม่ prop-drill และ test แยกจาก widget ตัวไหนตัวหนึ่งได้

สำหรับอะไรที่เกิน app เล่น ๆ layer หน้าที่พวกนี้เพื่อไม่ให้พันกัน:

  • UI layer — widget render state, ส่ง input ไม่มี business logic
  • State/logic layer — provider, BLoC, notifier ถือและ transition state นี่คือ layer ที่การพิสูจน์ทุกอันในสี่บทของโมดูลนี้เล็งไปตรง ๆ ด้วย test ที่ไม่มี widget
  • Data layer — repository และ service คุยกับ API, database, device

การเก็บ business logic และ data access ออกจาก widget คือสิ่งที่ทำให้ app test ได้และรอดเมื่อ app โตขึ้น — ไม่ว่าจะเลือก state package ตัวไหน บท internals Three Trees ครอบคลุมกลไกระดับ element/widget ที่ทุก layer ข้างบนขี่อยู่จริง ๆ

ทุกวิธีในโมดูลนี้ตอบคำถามสองข้อเดียวกันต่างกัน: “state อยู่ที่ไหนจริง ๆ?” กับ “ใครถูกบอกเมื่อมันเปลี่ยน และแม่นยำแค่ไหน?” setState เก็บคำตอบทั้งสองไว้ใน State object เดียว InheritedWidget/Provider ย้ายที่เก็บออกไปใน tree แต่กลไกการแจ้งยังเหมือนเดิม (dependency list ที่เช็คทุก update) Riverpod ย้ายที่เก็บออกจาก tree ไปเลยและแทนที่ dependency list ด้วย provider object ที่ typed ที่ขึ้นกันเองได้ BLoC เก็บ storage ไว้ใน object เดียวกันเหมือนกัน แต่แทนที่ “ใครก็ตามที่ขอล่าสุด” ด้วยชุด input ที่ปิดแล้วมีชื่อชัดเจน (sealed event) — การแลกความยืดหยุ่นกับความพิสูจน์ได้โดยตั้งใจ ไม่มีอันไหน “ดีกว่า” ในเชิงนามธรรม แต่ละอันคือจุดต่างกันบน trade-off เดียวกันของ scope/traceability/ceremony ที่พิสูจน์เป็นรูปธรรมในโมดูลนี้ ไม่ใช่แค่กล่าวอ้าง

ตัวเลือกข้อดีต้นทุน
setState ทุกที่ แม้แต่ shared stateไม่มี package เลย ไม่มี ceremonyprop-drilling จริงทันทีที่มีมากกว่าหนึ่งหน้าต้องการค่าเดียวกัน
Provider/Riverpod สำหรับทุกอย่าง แม้แต่ toggle local ของ widget เดียวสม่ำเสมอ — mental model เดียวทั้ง appoverhead จริง — provider, notifier, การ lookup ref/context สำหรับสิ่งที่ setState ทำได้บรรทัดเดียว
BLoC สำหรับทั้ง app รวมถึงหน้าเล็ก ๆpattern สม่ำเสมอ onboard ง่ายสำหรับทีมที่คล่อง BLoCsealed event class และ switch handler สำหรับ state ที่ไม่เคยต้องการเช็ค exhaustive ตั้งแต่แรก
ผสมทั้งสี่ตาม scope (คำแนะนำจริงของบทนี้)แต่ละชิ้นของ state ใช้ ceremony น้อยที่สุดที่ยังถูกต้องต้องการวิจารณญาณที่บทนี้มีไว้สร้าง — ไม่มีกฎเดียวแทนการจัดหมวด state ให้ถูกได้
  • “มี state management solution ที่ถูกต้องหนึ่งเดียวสำหรับทั้ง app” ทุกการพิสูจน์ในโมดูลนี้เป็นเรื่องกลไก ไม่ใช่คำสั่ง — app จริงผสม setState, Riverpod, BLoC ตาม scope เหมือน decision flow ข้างบนเป๊ะ
  • “Riverpod ดีกว่า Provider เสมอ เลยควรใช้ Riverpod ตลอด” บท Provider & Riverpodพิสูจน์ว่า Provider ไม่ได้ deprecated และยังเป็นตัวเลือกที่สมเหตุสมผลสำหรับ app เล็กหรือทีมที่คล่อง Provider อยู่แล้ว — ข้อดีของ Riverpod (.autoDispose, .select(), compile-safety) สำคัญที่สุดตรงจุดที่ decision flow ของบทนี้พาคุณไปพอดี: app ที่ใหญ่กว่า ซับซ้อนกว่า test ได้ดีกว่า by default
  • “sealed event กับ switch statement ของ BLoC เกินความจำเป็นทุกที่” มันเกินความจำเป็นสำหรับ checkbox และเป็นเครื่องมือที่ถูกต้องเป๊ะทันทีที่คุณต้องการให้ analyzer รับประกันว่าทุก event ถูกจัดการ — พิสูจน์ด้วย non_exhaustive_switch_expression จริงในบท BLoC
  • “lifting state up เป็นวิธีเก่าแล้ว หยิบ provider เลยดีกว่า” มันยังเป็นการเคลื่อนไหวแรกที่ถูกต้องสำหรับ sibling สองตัวที่ share ค่าเดียว การหยิบ shared store มาก่อนลองอันนี้คือการเพิ่ม dependency สำหรับปัญหาที่ constructor parameter ธรรมดาแก้ได้อยู่แล้ว
  • “ephemeral vs app state เป็นแค่ naming convention ไม่ใช่ความต่างจริง” มันกำหนดความถูกต้อง ไม่ใช่แค่สไตล์: ephemeral state ใน shared store จะอยู่นานเกินประโยชน์แล้ว leak; app state ที่ติดอยู่ใน setState ของ widget เดียวจะหายไปทันทีที่ widget นั้น rebuild จากศูนย์หรือ sibling ต้องการค่าเดียวกัน

ข้อผิดพลาดเชิงสถาปัตยกรรมที่ AI generate มาบ่อยที่สุดในโดเมนของโมดูลนี้ไม่ใช่ syntax bug เลย — มันคือการเลือก scope ผิดสำหรับ state ชิ้นหนึ่ง ซึ่ง compile สะอาดและโผล่เป็น bug จริง (drift, ข้อมูลเก่า, หรือ ceremony ซ้ำซ้อน) ก็ต่อเมื่อ app โตเกิน context window หน้าเดียวของ AI

// screen_a.dart — AI-generated, screen 1 of 3
class CartScreenA extends StatefulWidget {
const CartScreenA({super.key});
@override
State<CartScreenA> createState() => _CartScreenAState();
}
class _CartScreenAState extends State<CartScreenA> {
int _cartCount = 0; // ❌ app state, trapped in one screen's local setState
void _addItem() => setState(() => _cartCount++);
@override
Widget build(BuildContext context) {
return Scaffold(appBar: AppBar(title: Text('Cart ($_cartCount)')), body: /* ... */ const SizedBox());
}
}

badge cart บนหน้าอื่นทุกหน้า (list สินค้า, ปุ่ม checkout) ต่างก็ generate field _cartCount แยกของตัวเองแบบเดียวกัน — สี่สำเนาอิสระของสิ่งที่ควรเป็นค่า share เดียว แต่ละอันถูก update แค่จาก action local ของหน้าตัวเอง drift ไม่ตรงกันเงียบ ๆ ทันทีที่มีการเพิ่มของจากหน้าอื่นที่ไม่ใช่หน้าที่กำลังโชว์ badge เก่า

ก่อนเชื่อ state-management code ที่ generate มา เช็คสิ่งเหล่านี้:

  • มี screen/widget subtree มากกว่าหนึ่งที่ต้องการค่านี้ไหม? ถ้าใช่และมันถูก implement ด้วย local setState นั่นคือ bug เป๊ะแบบข้างบน — กลไก ephemeral ที่ถือ app state
  • field local ของ StatefulWidget ตัวหนึ่งเคยถูกอ่านหรือทำซ้ำใน StatefulWidget ตัวอื่น ไหม? การทำซ้ำนั้นคือสัญญาณ — ค่านั้นต้องการ lift หรือ shared store ไม่ใช่สำเนา local ตัวที่สอง
  • Riverpod/BLoC ถูกหยิบมาใช้กับค่าที่มีแค่ widget เดียวเคยอ่าน (index ของ PageView, flag กางของ Card ตัวเดียว) ไหม? นั่นคือข้อผิดพลาดตรงข้าม — overhead จริงที่พิสูจน์ได้ (provider/notifier/event class เพิ่ม) สำหรับค่าที่ setState จัดการได้บรรทัดเดียวอยู่แล้ว
  • สำหรับอะไรที่ share: มันถูกวางไว้ใน provider/notifier/BLoC ที่มีอายุนานกว่าหน้าที่สร้างมันครั้งแรกจริงไหม หรือถูกสร้างใหม่ (ค่าเดิมหายไป) ทุกครั้งที่หน้านั้นถูก push อีกครั้ง?

🐛 หา bug ในโค้ดนี้

// AI-generated: "add a dark mode toggle"
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _darkMode = false; // read by MaterialApp's theme, three screens up
@override
Widget build(BuildContext context) {
return SwitchListTile(
value: _darkMode,
onChanged: (v) => setState(() => _darkMode = v),
title: const Text('Dark mode'),
);
}
}
เฉลย

โค้ดนี้ compile สะอาดและ switch ก็สลับได้เห็นชัด — แต่ _darkMode เป็น local ของ State ของ SettingsScreen เอง และ comment ก็บอกใบ้ bug จริง: theme/darkTheme/themeMode ของ root MaterialApp ต้องการค่านี้ แต่มันอยู่ไกลออกไปสามหน้าจากที่ toggle นี้อยู่จริง การ toggle ตรงนี้ไม่เปลี่ยนอะไรเลยที่อื่นใน app — อาการคลาสสิกของ app state (อ่านโดย widget ที่อยู่ไกลและไม่เกี่ยวข้องกัน) ที่ implement ด้วยกลไก ephemeral (local setState มองเห็นได้แค่หน้านี้หน้าเดียว) วิธีแก้คือการจัดหมวดที่บทนี้สอนเป๊ะ ๆ: _darkMode คือ app state ดังนั้นมันควรอยู่ใน Riverpod Notifier/Provider ChangeNotifier ที่สร้างเหนือ MaterialApp โดยหน้านี้เรียก ref.read(themeModeProvider.notifier).toggle() (หรือตัวเทียบเท่าของ Provider) แทนที่จะถือสำเนาของตัวเอง

ควรลองอะไรก่อนเมื่อ sibling widget สองตัวต้องการ share state?
state ไหนควรอยู่ใน shared store มากกว่าใน widget เดียว?
ตามตาราง trade-off ที่รีเฟรชในบทนี้ ต้นทุนจริงที่พิสูจน์แล้วเฉพาะของสไตล์ codegen @riverpod ของ Riverpod (เทียบกับ provider เขียนมือ) คืออะไร?
บท BLoC & Patterns พิสูจน์อะไรเกี่ยวกับ testability ที่ตารางของบทนี้สะท้อนเป็นคอลัมน์คะแนนสูงสุดของ BLoC?
ในตัวอย่าง SpotTheBug dark-mode bug จริงคืออะไร และทำไม compile สะอาด?
ความต่างเชิงปฏิบัติของ "rebuild scope" ที่พิสูจน์ระหว่าง setState กับ buildWhen ของ BlocBuilder (หรือ .select() ของ Riverpod) คืออะไร?