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.

How this lesson proves things. Every code box lives in a real Flutter test file, 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

Think of a café. Whether your menu is open or closed matters only to you at your table — nobody else needs to know. Your order, though, must reach the kitchen, the cashier and the waiter, and it must not vanish when you close the menu. The first is like ephemeral state, the second like app state.

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.

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.

Putting everything in one place. Beginners either keep all data in one giant model (every small change then rebuilds half the app) or keep everything in widget 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?"
The 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.
Ephemeral state → a 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.

Three 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.
The "alive" check is an assertion — a check that only runs in debug builds. Release builds skip it, so the mistake does not show the same clear message there; never rely on it, always guard with 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.
setState = check alive → run the callback now → mark dirty → rebuild once next frame. The rebuild reaches every non-identical child; const children are skipped. After any await: if (!mounted) return;.

3. Lifting state up, and prop drilling

Two children share one toy. If each keeps "half" of it, they argue about who has it. Give the toy to the parent instead: the parent shows it to both (passes the value down), and when a child wants to play they ask the parent (call a callback up). There is only one toy, so nobody disagrees.

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:

Why prop drilling hurts: (1) every middle widget gets a parameter it does not care about, so adding one more piece of data means editing many files; (2) the middle widgets cannot be 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.
The callback the parent passes (() => 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.
Shared state → the nearest common parent. Values down, callbacks up. Too many layers in between (prop drilling) → use an InheritedWidget so far-away children can look the value up.

4. InheritedWidget, InheritedNotifier, InheritedModel

An InheritedWidget is like a building's notice board. Anyone inside can walk up and read it — no need for the message to be passed hand to hand through every floor. People who read it and say "tell me when this changes" go on a subscriber list. When the notice changes, only the subscribers get a knock on the door.

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.

Three InheritedWidget traps. (1) 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).
Debug-mode twist found by the tests. In debug builds (and 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).
InheritedWidget = data any descendant can look up in one step; readers that use 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

A ChangeNotifier is a shop with a mailing list. People who care sign up (add a listener). When something new arrives, the shop sends one message to everyone on the list, in the order they signed up. People who move away should unsubscribe — and when the shop closes for good (dispose), nobody may sign up or be mailed again.

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:

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:

Common ChangeNotifier mistakes: (1) forgetting 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).
While 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.
ChangeNotifier: fields + 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

A stream is a conveyor belt: items arrive one after another, over time. The "bloc" idea uses two belts. On the first you put requests ("add one", "reset"); a worker in the middle takes them one at a time, in order, and puts the result on the second belt. The screen just watches the second belt.

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.

Stream traps: (1) creating the stream inside 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.
The events in the bloc above are handled in order because a single-subscription stream delivers them one by one. If a handler does 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.
StreamBuilder: waiting → active → done. Bloc idea = events in, states out, logic in one testable class. Create streams once, close what you create, start broadcast listeners from the current state.

7. Provider, Riverpod and Bloc as ideas

Flutter gives you bricks (InheritedWidget, ChangeNotifier, streams). The popular packages are prefabricated walls built from those bricks: faster to put up, with handy fittings, but you still need to know what a brick is to fix a crack.

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.

If the app…A good first choiceWhy
Has state used by one widgetsetStateSimplest; nothing to share.
Shares a few values across nearby widgetsLift state up; ValueNotifier + ValueListenableBuilderNo package; rebuilds stay small.
Shares models across many screensInheritedNotifier, or ProviderLookup instead of drilling; listeners rebuild only.
Wants reading without context, compile-time safety, easy test overridesRiverpodProviders live outside the tree.
Has complex flows, many async steps, teams that want every change named and loggedBloc patternEvents in, states out; logic isolated and testable.
Interview trap: "Which is the best?" There is no single best. A strong answer names the trade-off: "For this size of app I would use X because …; if the team grew or the flows got more complex I would move to Y." Saying you would add a big package for a single counter is a red flag; so is saying you would never use one in a large app.
All three end up on the same Flutter machinery: an element that must be marked dirty, a 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.
Provider = InheritedWidget + ChangeNotifier, convenient. Riverpod = providers outside the tree, no context needed to read, overridable. Bloc = events → states streams. Pick by app size, team and testing needs, and say why.

8. Common state bugs

Most state bugs are like a notebook mix-up: writing in a new notebook every morning (and wondering where yesterday's notes went), keeping a photocopy of an old page (and trusting it), or staying on a mailing list after you have moved house.

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.

Spot these in code review: 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).
When a parent passes a different model object to a widget that listens in 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.
Create controllers once in State and dispose them; read widget.… at call time; every addListener needs a removeListener; move listeners when the model object changes; keep rebuilds small.

Quiz

Interview questions

Cheat sheet

ConceptFact
Ephemeral vs app stateOne 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 disposesetState throws FlutterError "setState() called after dispose()" (debug). Guard with if (!mounted) return; after await.
Lifting state upNearest common parent; values down, callbacks up. Many layers → prop drilling.
InheritedWidgetdependOn… = one-step lookup + dependency. updateShouldNotify true → dependents rebuild. Keep the child const.
InheritedNotifierListens to a Listenable; a notification rebuilds dependents next frame, once. A different notifier object also notifies.
InheritedModelInheritedModel.inheritFrom<T>(context, aspect: …); updateShouldNotifyDependent decides per aspect. No aspect = every change.
ChangeNotifieraddListener / removeListener / notifyListeners(); listeners run in order; use after dispose throws.
ValueNotifierNotifies only when the new value is not == the old one.
BuildersValueListenableBuilder, ListenableBuilder: rebuild only the builder; child built once; remove their listener, never dispose the notifier.
StreamBuilderwaiting → active → done; an error snapshot has no data; create the stream once; broadcast streams do not replay.
Bloc ideaEvents in, states out; logic in one testable class; queue async handlers to keep order.
Provider / Riverpod / BlocInherited + ChangeNotifier made easy / providers outside the tree, no context to read / events → states with tooling.
BugsControllers in build, stale closures, missing removeListener, notify during build, one giant model.