State Management in Flutter
By the end of this lesson you will be able to say where every piece of data in a Flutter app should live, and explain — the way a senior interviewer expects — exactly which widgets rebuild when it changes: with setState, by lifting state up, with InheritedWidget and its two cousins, with ChangeNotifier and ValueNotifier, and with streams. You will build tiny working versions of the ideas behind Provider, Riverpod and Bloc using only the Flutter SDK, so you know what those packages add and how to choose. Every claim is proved by a real Flutter test.
verify_flutter/test/f02_test.dart. A widget test starts a pretend screen with no window, puts widgets on it with tester.pumpWidget(...) and draws the next frame with tester.pump(). The // => lines under each box are what the test really printed. This lesson builds on Step 12.1 (widgets, elements, setState, BuildContext); open it if a word here is new. No extra packages are used anywhere: Provider, Riverpod and Bloc appear only as ideas, rebuilt in a few lines of plain Flutter.1. What "state" is, and where it lives
State is any data that can change while the app runs and that the screen depends on: a counter, the text typed into a box, whether a panel is open, the items in a cart, the logged-in user. When state changes, some widgets must be built again so the screen shows the new value.
- Ephemeral (UI) state belongs to one widget: the selected tab, whether a panel is expanded, an animation's progress. It lives in that widget's
Stateobject and is changed withsetState. When the widget leaves the tree (the user goes to another screen), itsStateis thrown away — and that is fine. - App state is shared or must outlive one screen: the cart, the user, settings, data from the server. It lives in a model object (a plain Dart object that holds data and tells listeners when it changes) created above the widgets that use it.
The test below keeps one of each. After the panel leaves the tree and comes back, its open flag has reset (a brand-new State), while the cart, which lives in a model above, still holds the tea.
State objects (data is lost when a screen closes, and two screens cannot share it). Ask one question for each piece of data: "Who needs it, and must it survive when this widget goes away?"State object is owned by the widget's element (the long-lived instance at that spot in the tree, from Step 12.1). When the element is removed for good, Flutter calls dispose() and the State is garbage. A model created above lives as long as whoever created it — often a State high in the tree, or a variable created before runApp.State object + setState. App state → a model object above the widgets that need it. Choose by "who needs it?" and "must it survive this widget?".2. setState done right
setState is like putting a "please repaint" sticker on one room of a house. The painter does not run in at once; they come on their next round (the next frame), repaint every room with a sticker — once, however many stickers you added — and leave rooms without stickers alone.Calling setState(() => n++) does three things, in this order: (1) a debug check that this State is still alive, (2) runs your little function right away, so n changes at once, (3) marks the element dirty ("needs a rebuild") and asks for a new frame (one picture on screen). Nothing is rebuilt yet. At the next frame, Flutter calls build() on every dirty element, once each.
What does that rebuild cost? The build of this widget, plus the build of every child widget it returns that is not the very same object as last frame. A const child is the same object every time, so Flutter skips it.
After a State is disposed it must never call setState again. In debug builds (and in tests) Flutter checks this first and throws a FlutterError whose message starts with setState() called after dispose(). Your function is never run:
This usually happens after an await. Between starting a network call and getting the answer, the user may close the screen. The fix is one line: check mounted (true while the element is in the tree) right after the await.
setState mistakes. (1) Changing a field without setState: the data changes but nobody asks for a rebuild, so the screen shows old values until something else rebuilds (question q3). (2) Doing slow work inside the callback: the callback should only assign fields; do loading before, then call setState with the result. (3) Passing an async callback: Flutter rejects setState(() async {...}) with a FlutterError, because the field would change later, after the frame was drawn.mounted. Inside, setState ends with markNeedsBuild() on the element, which adds it to the dirty list owned by the BuildOwner. A second setState before the frame finds the element already dirty and adds nothing. That is why the test printed Tally: 2, not 3.const children are skipped. After any await: if (!mounted) return;.3. Lifting state up, and prop drilling
When two widgets need the same data, move it to their nearest common parent. This is called lifting state up. The rule of thumb: data flows down through constructor parameters, and events flow up through callbacks (functions the parent hands to the child, such as onPressed). The child never changes the data itself; it reports, and the parent decides.
Lifting works well for a few levels. It hurts when the common parent is far away. Then every widget in between must accept the value in its constructor and pass it on, even if it never uses it. This is called prop drilling. Below, only Avatar shows the name, but Shell and Sidebar must carry it — and when the name changes, all three rebuild:
const, because they carry changing data, so they rebuild on every change; (3) moving a widget somewhere else in the tree means re-plumbing the parameters. The next section fixes this with a widget that children can look up instead of being handed the data.() => setState(() => count++)) is a closure: a function that remembers the State it was created in. Because it is a new closure object on every parent build, the PlusButton widget is a new object too and rebuilds whenever the parent does. That is cheap here; for big subtrees you would pass a method like increment (the same tear-off of the same object compares equal) or move the state lower.4. InheritedWidget, InheritedNotifier, InheritedModel
An InheritedWidget holds data for everything below it. A widget deep inside calls context.dependOnInheritedWidgetOfExactType<T>() (usually wrapped in a static of(context) method) to read it. This lookup is one quick step (each element keeps a small table "inherited type → nearest one above me", see Step 12.1), and it also registers a dependency: the reader becomes a dependent of the inherited widget.
When the parent rebuilds and gives a new inherited widget, Flutter asks it updateShouldNotify(oldWidget): "did anything that matters change?". If it returns true, every dependent is marked dirty and rebuilds. If false, nobody is told. Widgets in between that did not read the data are not touched — as long as they are the same objects (const).
A plain InheritedWidget only changes when its parent rebuilds with a new one. Usually the data lives in a model that changes by itself (a cart, a counter). InheritedNotifier joins the two ideas: it is an InheritedWidget that also listens to a Listenable (anything you can subscribe to, such as a ChangeNotifier, explained in section 5). When the model calls notifyListeners(), its dependents rebuild in the next frame — no parent rebuild needed. That is the core of what the Provider package does; here is a tiny version, Provide, written with the SDK alone:
Sometimes one inherited object holds several unrelated things — a theme and a language. With a plain InheritedWidget, a language change would rebuild every widget that only shows the theme. InheritedModel lets each reader say which aspect (part) it depends on, and its updateShouldNotifyDependent decides per reader:
Try it: where should the state live?
Describe your own small tree, choose which widget holds the state, and which widgets actually use the value. The player runs a JavaScript model of Flutter's rule twice — once with setState in the holder (values passed down), once with the holder providing a notifier through Provide (only users listen) — and shows exactly which widgets rebuild. The test file builds the same trees in real Flutter and checks the model on 150 random trees.
updateShouldNotify returning true always: every parent rebuild then rebuilds all dependents. Compare only the fields readers show (question q13). (2) A non-const child: if the parent writes UserScope(child: HomePage()) without const, HomePage is a new object every time and rebuilds anyway — updateShouldNotify controls only the notification, not ordinary rebuilds (question q14). (3) Reading with getInheritedWidgetOfExactType in build: it does not register a dependency, so the widget keeps showing an old value (question q10).flutter test) every const widget remembers where it was written in the source, so two const HomePage() written on two different lines are two different objects, and the second one makes HomePage rebuild. One const expression evaluated twice is still the same object. That is why the tests wrap the tree in a small function such as app(name). Profile and release builds share equal const objects everywhere. Inside InheritedNotifier, a notification only sets a "dirty" flag on its element and marks it for rebuild; during the next frame the element sees the flag and notifies its dependents — so three notifications before a frame still mean one rebuild (question q15).dependOn… become dependents and rebuild when updateShouldNotify says yes. InheritedNotifier = the same, driven by a Listenable. InheritedModel = per-aspect dependencies. Keep the child const, or ordinary rebuilds swamp the savings.5. ChangeNotifier, ValueNotifier and the builders
ChangeNotifier is a class from Flutter's foundation library. You extend it, keep your data in fields, and call notifyListeners() whenever the data changes. Listeners are plain functions added with addListener and removed with removeListener. ValueNotifier<T> is a ready-made ChangeNotifier that holds one value and notifies automatically when you assign a value that is not equal (==) to the old one.
To show a notifier on screen you rarely call addListener yourself. Two builder widgets do it for you, and they rebuild only their own builder function:
ValueListenableBuilder<T>— listens to aValueListenable(such as aValueNotifier) and callsbuilder(context, value, child).ListenableBuilder— listens to anyListenable(such as your ownChangeNotifier) and callsbuilder(context, child).
Both take an optional child: a widget built once and handed back to every builder call, for parts that do not depend on the value. Both remove their listener when they leave the tree. They do not dispose the notifier — whoever created it must.
Using a notifier after dispose() is a bug that debug builds catch at once:
notifyListeners() after changing a field — the data changes, the screen does not; (2) notifying when nothing changed — every listener rebuilds for nothing (question q9); (3) one giant model for the whole app — every change rebuilds every listener (question q25); split it, or use ValueNotifiers per field; (4) never disposing a notifier you created, or disposing one you did not create (question q11).notifyListeners() runs, it is safe for a listener to add or remove listeners; the change takes effect for the next notification. If a listener throws, ChangeNotifier catches the error, reports it through FlutterError.reportError (so it is printed, and tests see it with tester.takeException()), and goes on calling the remaining listeners. Section 8 shows a leaked listener being caught exactly this way.notifyListeners(). ValueNotifier: one value, notifies only when the new value is not == the old one. ValueListenableBuilder / ListenableBuilder rebuild only their builder; pass static parts as child. The creator disposes the notifier.6. Streams and the "bloc" idea
You met Stream and StreamController in the Dart async lessons: a controller is the input end, controller.stream is the output end. StreamBuilder listens to a stream and rebuilds its builder with an AsyncSnapshot for each event. The snapshot's connectionState tells you where you are: waiting (subscribed, nothing yet), active (events are arriving), done (the stream closed).
A stream event is delivered in a microtask (a tiny job Dart runs as soon as the current code finishes). In widget tests that is why the code above uses tester.pump(Duration.zero): it lets those jobs run first, then draws the frame. A plain tester.pump() decides whether to draw before the event has arrived — the first version of this test caught that.
The bloc idea (from "business logic component"): the screen sends events in; a class turns each event plus the current state into a new state; new states go out on a stream; the screen rebuilds from them. The business rules live in one place, are easy to test without widgets, and every change has a name. Here it is with the Dart SDK only:
And the screen side: a StreamBuilder on the bloc's states. Because a broadcast stream (one many listeners can share) does not replay old events, the builder starts from initialData: bloc.state.
build (for example stream: bloc.states.map(...)) gives a new stream every build, so StreamBuilder unsubscribes and subscribes again each time (question q26); create it once. (2) A late listener on a broadcast stream misses everything sent before it subscribed (question q19). (3) Forgetting to close controllers you created. (4) Listening twice to a single-subscription stream throws Bad state: Stream has already been listened to.await work, a plain listen would start the next event before the previous one finished, and results could arrive out of order. Question q30 builds a queue that waits for each handler to finish. The sealed event class lets the switch be checked by the compiler: add a new event type and forget to handle it, and the code no longer compiles.7. Provider, Riverpod and Bloc as ideas
Real apps often use a package from pub.dev for state. They do not replace the ideas above — they package them. This lesson does not teach any package's exact API (it changes between versions; read each package's documentation); it teaches what each one is, so you can explain and choose.
- Provider — InheritedWidget + ChangeNotifier made convenient. It is what our
Providewidget does, plus: creating the model for you and disposing it when that part of the tree goes away, short helpers to read with or without listening, combining several providers, and rebuilding only when a selected part changes. - Riverpod — from the same author, built around the opposite choice: providers are declared outside the widget tree as global, typed objects, and a container holds their values. Reading a provider does not need a
BuildContext, and a missing provider cannot be "forgotten" in the tree, so the mistakeProvide.ofcan only report at run time (question q29) does not exist. Providers can read other providers, are created lazily and cached, and can be overridden in tests. OurPodsketch below shows the core idea in a few lines. - Bloc — the events-in / states-out pattern from section 6, with library support: a base class that maps events to states, widgets that rebuild on new states or react once to them (for navigation or messages), and tools that log every transition (event, old state, new state).
| If the app… | A good first choice | Why |
|---|---|---|
| Has state used by one widget | setState | Simplest; nothing to share. |
| Shares a few values across nearby widgets | Lift state up; ValueNotifier + ValueListenableBuilder | No package; rebuilds stay small. |
| Shares models across many screens | InheritedNotifier, or Provider | Lookup instead of drilling; listeners rebuild only. |
| Wants reading without context, compile-time safety, easy test overrides | Riverpod | Providers live outside the tree. |
| Has complex flows, many async steps, teams that want every change named and logged | Bloc pattern | Events in, states out; logic isolated and testable. |
build that runs in the next frame, and listeners that must be removed. That is why the bugs in section 8 appear with every package, and why interviewers ask about InheritedWidget and ChangeNotifier even when the job uses a package.8. Common state bugs
Bug 1: creating a controller in build. build runs again and again. Anything created there is created again: a TextEditingController made in build starts empty on every rebuild, so the typed text vanishes (and the old controller is never disposed). Create it once in the State and dispose it in dispose().
Bug 2: a stale closure. A closure created once (in initState, or a callback stored in a field) keeps whatever it captured. If it captured a copy of widget.name, it never sees the new name the parent passes later. Read widget.… when the code runs, or update the stored value in didUpdateWidget.
Bug 3: listening without removing. A State that calls model.addListener in initState must call removeListener in dispose. Otherwise the model keeps a reference to the dead State (a memory leak), and the next notification calls setState after dispose. ChangeNotifier catches that error and reports it:
Bug 4: rebuilding too much. The setState high in the tree, the one big model everyone listens to, the inherited widget whose child is not const. The cure is always the same: move the changing state down to the smallest widget that needs it, listen with a builder around only the part that changes, pass static parts as child, and make unchanging children const.
TextEditingController(), ScrollController(), AnimationController(...), FocusNode() or .stream.map(...) written inside build; addListener with no matching removeListener; initState copying widget.something into a closure; await followed by setState with no mounted check; notifyListeners() called inside build (it throws setState() or markNeedsBuild() called during build., question q21).initState, the widget keeps listening to the old one: the State is kept (same type, same key), only widget changes. Override didUpdateWidget(oldWidget), compare oldWidget.model with widget.model, and move the listener (question q23). The builder widgets of section 5 already do this internally.widget.… at call time; every addListener needs a removeListener; move listeners when the model object changes; keep rebuilds small.Quiz
Interview questions
Cheat sheet
| Concept | Fact |
|---|---|
| Ephemeral vs app state | One widget cares → State + setState. Shared or must survive → model object above. |
setState(fn) | Debug alive check → run fn now → mark dirty → one rebuild next frame. Non-identical children rebuild; const ones are skipped. |
| After dispose | setState throws FlutterError "setState() called after dispose()" (debug). Guard with if (!mounted) return; after await. |
| Lifting state up | Nearest common parent; values down, callbacks up. Many layers → prop drilling. |
InheritedWidget | dependOn… = one-step lookup + dependency. updateShouldNotify true → dependents rebuild. Keep the child const. |
InheritedNotifier | Listens to a Listenable; a notification rebuilds dependents next frame, once. A different notifier object also notifies. |
InheritedModel | InheritedModel.inheritFrom<T>(context, aspect: …); updateShouldNotifyDependent decides per aspect. No aspect = every change. |
ChangeNotifier | addListener / removeListener / notifyListeners(); listeners run in order; use after dispose throws. |
ValueNotifier | Notifies only when the new value is not == the old one. |
| Builders | ValueListenableBuilder, ListenableBuilder: rebuild only the builder; child built once; remove their listener, never dispose the notifier. |
StreamBuilder | waiting → active → done; an error snapshot has no data; create the stream once; broadcast streams do not replay. |
| Bloc idea | Events in, states out; logic in one testable class; queue async handlers to keep order. |
| Provider / Riverpod / Bloc | Inherited + ChangeNotifier made easy / providers outside the tree, no context to read / events → states with tooling. |
| Bugs | Controllers in build, stale closures, missing removeListener, notify during build, one giant model. |