Because state that several widgets read, or that outlives a screen, does not fit in one State: you end up passing values and callbacks through every constructor, logic stays untestable inside widgets, and rebuilds are too broad.
Prop drilling: a value needed five levels down travels through widgets that do not use it, and every signature changes with the data
Testability: logic inside State classes is testable only through widgets; a separate store or notifier is plain Dart
Lifetime: a State dies with its widget, so data that must survive leaving the screen (the cart, the signed-in user) needs an owner above the screens
Rebuild scope: setState high in the tree rebuilds the whole subtree; a store lets only the readers of one value rebuild
Not always: purely local UI state (a selected tab, a checkbox) is still best kept in setState
FlutterJunior
Basic State Management
setState, InheritedWidget, ValueNotifier
Questions closed
0 / 23
- 0Review
- 8Asked often
Questions
Built in: setState, InheritedWidget, ValueNotifier/ChangeNotifier; packages: Provider, Riverpod, BLoC/Cubit, rarely MobX, Redux, GetX. Rule of thumb: setState for local UI, Provider or Riverpod for shared state, BLoC for large teams that want strict events.
**setState and the built-ins: local UI state; no dependency, but nothing is shared beyond the subtree
Provider**: a thin wrapper over InheritedWidget that creates, disposes and exposes objects; Riverpod, by the same author, removes its dependence on BuildContext
Riverpod: providers declared outside the tree, compile-time safety, built-in async (AsyncValue) and easy overrides in tests
BLoC/Cubit: events in, states out over streams; more code, but every state change is explicit and traceable, which large teams value
How to choose: team experience, app size, amount of async work, testability and package maintenance; one library for shared state plus setState for local UI, not two competing for the same feature
setState runs your closure right away, then marks this State's element dirty and schedules a frame. On that frame Flutter calls build() again and compares the new widgets with the existing elements.
What gets rebuilt: everything this build returns is recreated as widgets; elements and render objects are reused where type and key match, and an identical const child is skipped entirely
Not immediate: the rebuild waits for the next frame, so several calls in one event cost one build
Keep it low: call it in the smallest widget that owns the changing data, not on the whole page
**Not inside build**: build must be a pure function of state and can run on every frame, so it must not change state. Calling setState on the same State there achieves nothing (the element is already dirty); dirtying an ancestor or another already-built widget (a callback to the parent's setState, a synchronous notifyListeners) throws "setState() or markNeedsBuild() called during build"
**Not after dispose**: check mounted after every await before calling it
count is a local variable of build. setState does increment it, but the rebuild it schedules runs build again, which declares a fresh count = 0, so the screen always shows 0.
The fix: make it a field of the State: int _count = 0; above build. The State object survives rebuilds; locals in build and fields of the StatefulWidget do not
Mutated in place: you added to the same List, and a child that compares oldWidget.items != widget.items sees the same object and skips its work
Quick check: put a debugPrint in build: no print means nothing was marked dirty, a print with the old value means the data is wrong
Lifting state up means moving a value to the lowest common parent of the widgets that read or change it, then passing the value down as a parameter and changes back up through callbacks.
Two siblings cannot talk to each other; they can only meet in a shared parent. Put the value there and both see the same truth.
When: as soon as a second widget needs to read or change a value that one widget owns
One source of truth: no mirrored copy to drift out of sync (the classic _local = widget.value bug)
How far: to the nearest ancestor that needs it, not higher
When to stop: if you are threading a value through four layers of constructors, switch to an InheritedWidget-based solution (Provider, Riverpod)
setState runs your closure immediately and then marks the element dirty. An async closure returns a Future the framework throws away, so the fields are assigned long after the rebuild they were meant to cause.
What happens: in debug: "setState() callback argument returned a Future"; in release nothing checks it: the Future is ignored, the fields are assigned after the rebuild, and an error inside it becomes an unhandled async error
Why the compiler is quiet: () async {} is a Future<void> Function(), and that is assignable where a void Function() is expected
The shape that works: await outside, check mounted, then setState with the result already in hand
Keep the closure to assignments: no I/O, no navigation, nothing that can throw
Everything the State created that outlives one build: controllers, subscriptions, timers and listeners keep running or hold native resources, and the ones with callbacks keep the State alive.
The checklist: controllers you constructed (TextEditingController, ScrollController, AnimationController, FocusNode), stream subscriptions, timers, listeners you added, and video or camera controllers
What forgetting costs: the callback holds this, so the State and its whole subtree are never collected; a timer firing later hits a dead State ("setState() called after dispose()"), and a leaked AnimationController keeps scheduling frames
Two rules: call super.dispose() last, and never dispose an object the parent gave you: it belongs to the parent
It prints after: 3 first and then build: 3 once. Each setState runs its closure immediately, but all three only mark the same element dirty, and the rebuild happens once, on the next frame.
The closure is synchronous: _count is already 3 on the line after the calls
The rebuild is not: markNeedsBuild on an already dirty element returns early, so N calls in one event cost one build
No need to batch by hand: but one setState around all related changes reads better
What is not ready yet: the new layout. Sizes, scroll extents and RenderBox values still describe the old frame; read them in WidgetsBinding.instance.addPostFrameCallback
One bool _saving on the State: set it before the await, clear it in a finally when the screen stays open, and pass onPressed: null while it is set, so the button is visibly disabled during the work.
Why one boolean is enough: your handler runs on a single thread, so if (_saving) return; and the assignment happen in one slice; a second tap cannot slip between them
**Clear it in finally, except on a success that closes the screen**: clearing only on success leaves the button dead after one exception; after a success that pops the screen, leave the flag set, or the button is live again during the transition; guard the setState with mounted
Not a debounce: a debounce delays the first call and does nothing about a second one while the first is in flight
A UX fix only: if the order must never be created twice, the server needs an idempotency key: a rebuilt widget starts at false again
It throws: in debug "setState() called after dispose()", in release a null-check error. While you await, the user can leave and the State gets disposed; mounted says whether it is still in the tree, so check it before touching state or context.
The pattern: after every await in a State method, write if (!mounted) return; before setState, context or navigation
**Don't use context across the gap**: capture what you need first, e.g. final messenger = ScaffoldMessenger.of(context);, then use it after the await
Clean up: cancel timers, subscriptions and pending work in dispose, so nothing can call back into a dead State
Let the analyzer help: the use_build_context_synchronously lint catches the common cases; outside a State (a StatelessWidget, a callback) use context.mounted
Both run before the first build, but initState runs exactly once, while didChangeDependencies runs right after it and again every time an inherited widget this State depends on changes.
**initState**: create controllers, notifiers, subscriptions; start a one-off load. You cannot call Theme.of or MediaQuery.of here: Flutter throws because the State has not finished initialising
**didChangeDependencies**: the first place .of(context) is allowed, and where you react to a theme, locale or scope change, e.g. re-subscribing to a repository read from the context
**didUpdateWidget(old): the related hook: the parent rebuilt this widget with new constructor arguments; compare them here
Don't copy widget fields**: read widget.title in build; a copy made in initState goes stale because the State is reused (sync it in didUpdateWidget)
Common mistake: an expensive fetch in didChangeDependencies with no guard; it runs again for any dependency change, not just the one you care about
_qty is copied from widget.quantity only in initState. When the parent rebuilds with a new quantity, Flutter reuses the same State, initState does not run again, and the stepper keeps showing its stale copy.
**Why the State survives**: the widget at that position has the same type and key, so the element calls didUpdateWidget(oldWidget) on the existing State instead of creating a new one; only widget is swapped
The fix: override didUpdateWidget and resync the copy when the incoming value differs from oldWidget.quantity; compare first, so a rebuild with the same value does not wipe a local edit
Better still, keep no copy: if the parent owns the number, read widget.quantity in build and call widget.onChanged(widget.quantity + 1): a controlled widget cannot drift
Not a fix: a ValueKey(quantity) on the stepper forces a fresh State, but it also throws away the rest of its state (focus, animations) on every change
Catch it: a widget test that pumps the stepper with quantity: 5, pumps again with quantity: 1 and expects find.text('1')
Flutter is already walking the tree when build runs, and marking an ancestor or a widget that was already built as dirty in that window would leave the frame half built, so it refuses.
What triggers it: a child calling the parent's setState through a callback, or a synchronous notifyListeners(), from build or initState (both run inside the parent's build), showing a snackbar or dialog, or navigating from a lifecycle method
Do not store what you can compute: if the value is derived from the widget's inputs, calculate it in build and delete the field
Move the trigger: side effects belong in the callback that caused them: onPressed, a listener, a stream handler
When it really must follow a change: defer it one frame with WidgetsBinding.instance.addPostFrameCallback, guarded by mounted
React to state changes with a listener: BlocListener, ref.listen or an addListener from initState; build is not a place to react
The dialog is a separate route: your page's setState rebuilds the page, while the dialog's content was built by the builder you handed to showDialog and is not part of the page's subtree.
**StatefulBuilder**: the smallest fix: wrap the dialog's content and you get a local setState scoped to that builder, enough for one checkbox or slider
**A real StatefulWidget**: better once the dialog has several fields: you get initState and dispose for controllers, and it can be tested on its own
**ValueNotifier + ValueListenableBuilder: when the value also matters outside the dialog
Returning the answer**: Navigator.pop(context, value) inside, await showDialog<bool>(...) outside, then check context.mounted
ValueNotifier<T> is a ChangeNotifier that holds one value and notifies its listeners when that value is replaced. Paired with ValueListenableBuilder, only the small subtree inside the builder rebuilds, no package needed.
**vs ChangeNotifier**: a ChangeNotifier holds any number of fields and you call notifyListeners() yourself; ValueNotifier notifies on assignment, and only if the new value is != the old one
**Prefer it over setState: when one leaf depends on the value (a counter, an icon, an enabled button) while the rest of the screen should not rebuild
Equality is ==**: mutating a List in place notifies nobody; assign a new list
Dispose it: you created a Listenable, so release it in dispose
Its limit: one value; when several fields must change together, use a ChangeNotifier or a real store
child is built once, outside the builder, and passed back into it unchanged. Because it is the same widget instance every time, the element reuses it and never rebuilds that subtree.
Identity is the whole trick: Element.updateChild compares the new widget with the current one; the same instance means build is never called on it (the rule that makes const subtrees free)
How to split: only the widgets that read the value go inside builder; everything static and expensive goes into child
Keep the builder low: a builder near the root still rebuilds a page's worth of widgets, whatever you pass as child
Same parameter elsewhere: ListenableBuilder, AnimatedBuilder and provider's Consumer all have it, for exactly this reason
In the TextEditingController. It is already a ValueNotifier<TextEditingValue> that the TextField listens to, so the field repaints itself on every character and nothing above it needs to rebuild.
One source of truth: read controller.text when you submit; a mirrored String _text field is a second truth that will drift
When something else depends on the text: an enabled Send button, a character counter. Wrap just that widget in a ValueListenableBuilder on the same controller
For validation: Form + TextFormField with a validator and autovalidateMode: AutovalidateMode.onUserInteraction; each field keeps its own error
**onChanged is not the problem**: it is still the right hook for a search debounce or pushing text into a bloc; the mistake is the setState
InheritedWidget hands data to everything below it without passing it through every constructor, and rebuilds only the widgets that read it. Theme.of, MediaQuery and Provider are built on it; updateShouldNotify decides whether readers rebuild.
A descendant calls .of(context) (really dependOnInheritedWidgetOfExactType) and is registered as a dependent. When the parent rebuilds the inherited widget with a new instance, Flutter calls updateShouldNotify(oldWidget); if it returns true, every dependent is marked dirty.
**updateShouldNotify**: compare the fields that matter (oldWidget.primary != primary); returning true always works but rebuilds dependents on every parent rebuild
New instance, not mutation: the widget is immutable; to change data, a StatefulWidget above rebuilds it with new values
Fast lookup: each element keeps a map of the inherited widgets above it, so .of(context) costs the same at any depth
Common mistake: calling .of(context) in initState: do it in didChangeDependencies or build
Provider is built on InheritedWidget. It adds what the raw widget lacks: creating and disposing the object, rebuilding dependents when a ChangeNotifier notifies, reading without subscribing, and selecting a single field.
**Raw InheritedWidget is immutable**: to change the value you rebuild it from a StatefulWidget above, and you write of() and updateShouldNotify by hand
Lifecycle: ChangeNotifierProvider(create: ...) creates the object lazily and calls dispose when the provider leaves the tree; with a raw widget you own both
Listening: Provider subscribes to the Listenable and notifies dependents; in the SDK alone, InheritedNotifier does the same job
Access modes: context.watch subscribes, context.read only fetches (for callbacks), context.select rebuilds on one field; plain InheritedWidget gives you only "depend" and "get"
Errors: a missing provider throws ProviderNotFoundException with an explanation instead of a bare null
When raw is enough: a package or design system exposing a few immutable values, like Theme, without adding a dependency
GetX bundles state, navigation, DI, translations and HTTP into one package built on global singletons and context-free APIs. It is fast to start with, but it hides lifecycles, ties the whole app to one package and makes testing and upgrades harder.
Global service locator: Get.put and Get.find are global; dependencies are invisible in constructors, and tests must reset the registry between cases
Bypasses the widget tree: Get.to and Get.snackbar without a BuildContext work around Flutter's model; controller lifetimes tied to routes become behaviour you debug instead of code you read
All-in-one lock-in: routing, state and DI are tangled, so replacing one part means rewriting the others
Reactive magic: .obs variables and Obx hide what rebuilds, and reading one outside an Obx silently never updates
removeListener gets a new closure that was never added, so it removes nothing. The State stays subscribed after dispose: the cart keeps it in memory, and the next notifyListeners calls setState on a dead State.
Why: every () => setState(() {}) creates a new function object, and two closures are never ==; ChangeNotifier.removeListener looks the listener up by ==, finds no match and silently returns
The fix: register a method tear-off: _onCartChanged taken twice from the same object gives equal values, so removeListener finds what addListener stored. Keeping the closure in a final field and passing that same object works too
What it costs: the cart, often app-wide, holds the closure, the closure holds this, so the State and its subtree are never collected; in debug each cart change reports "setState() called after dispose()", in release it is a null-check error, and opening the screen N times leaves N stale listeners
Simpler: ListenableBuilder(listenable: widget.cart, builder: ...) subscribes and unsubscribes for you; if the parent can pass a different cart, a manual listener also has to move in didUpdateWidget
Catch it: a widget test that pumps the badge, replaces it with a SizedBox and then changes the cart fails with that error
Three links have to hold: the inherited widget must be a new instance with a new value, its updateShouldNotify must return true, and the reader must actually have registered a dependency. One of them is broken.
Mutated in place: you added to the same List, so the new widget's field is the same object as the old one and updateShouldNotify compares it with itself; publish a new value (_items = [..._items, item])
Read without subscribing: only dependOnInheritedWidgetOfExactType registers the caller; getInheritedWidgetOfExactType just returns the current widget and is meant for callbacks and one-off reads. It also works in initState, while dependOn... throws there until the State has initialised; read in didChangeDependencies or build when you need rebuilds
**updateShouldNotify returns false: it gates the notification, so the whole pass stops and no dependent rebuilds
No new instance ever reaches the tree**: the data changed in a State that never called setState, or the InheritedWidget is cached in a field; the const child under it is why nothing else rebuilds
Use InheritedModel: a dependent names an aspect when it reads, and the framework asks per dependent whether the aspects that widget cares about actually changed.
How it runs: a descendant reads with InheritedModel.inheritFrom<Prefs>(context, aspect: PrefAspect.currency), and the aspect is stored with the dependency; on update Flutter calls updateShouldNotify once, then updateShouldNotifyDependent(old, aspects) per dependent and rebuilds only those that answer true
Flutter uses it itself: MediaQuery is an InheritedModel; that is why MediaQuery.sizeOf(context) ignores the keyboard while MediaQuery.of(context) rebuilds on every metric
Aspects must be value-comparable: an enum; something compared by identity registers a dependency that never matches again
Filters rebuilds, not work: a dependent that rebuilds rebuilds entirely, and inheritFrom with no aspect depends on everything
Usually simpler: context.select (provider) or ref.watch(p.select(...)) (Riverpod) give the same filtering for a fraction of the code