setState: What It Really Does
Welcome to Part 1 of the Flutter State Management series. Every Flutter app, from a counter to a banking app, is really just one question asked over and over: "the data changed — how does the screen catch up?" That's state management, and this series builds your understanding from the humblest tool (setState) all the way to Provider, Riverpod, and Bloc.
We start where Flutter itself starts you: setState. You've almost certainly typed it. But do you know what it actually does when you call it — and, more importantly, the exact point where it quietly becomes the wrong tool? Get this right and every fancier solution later in the series will feel like a natural response to a problem you've personally felt.
New here? This series assumes you know widgets and the build method from the Flutter Fundamentals series — especially Stateless vs Stateful and the build method. We're on Flutter 3.38 / Dart 3.12.
What "state" even means
State is just data that can change over time and affects what you see. A counter's value, whether a checkbox is ticked, the list of todos, the current tab — all state. Flutter's whole job is to keep the pixels in sync with the data.
The core Flutter equation:
UI = f(state). The screen is a function of your state. Change the state, re-run the function, get new pixels. Everything in this series is about who holds the state and how the re-run gets triggered.
setState is the most direct possible answer to "how does the re-run get triggered": you call it, by hand, on a StatefulWidget's State.
The mental model: a whiteboard you redraw
Analogy — the whiteboard. Imagine the screen is a whiteboard and build() is you redrawing it. The marker drawing reflects some numbers in your head (the state). When a number changes, the board is now stale — it shows the old value.
setState is you saying: "I've updated the numbers in my head; please wipe the board and draw it again." You don't redraw the board yourself inside setState — you just flag that it's stale, and Flutter redraws it for you at the next opportunity.
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0; // ← the state lives in the State object
void _increment() {
setState(() {
_count++; // mutate state INSIDE the callback
});
}
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: _increment,
child: Text('Count: $_count'), // build() reads the state
);
}
}
Tap the button → _increment runs → setState flags the State as dirty → Flutter re-runs build() → Text shows the new _count. The data changed, and the pixels caught up.
What setState actually does (under the hood)
Here's the part most tutorials skip. setState does not repaint the screen. It does almost nothing visible itself. Precisely:
- It runs your callback synchronously — that's where you mutate the fields.
- It marks this
State's Element as dirty by callingmarkNeedsBuild()on it. - It returns immediately. Nothing is drawn yet.
Then, on the next frame, Flutter's pipeline walks the dirty elements, calls build() on each, and reconciles the result against the three trees — rebuilding only what changed.
// These are equivalent in EFFECT — the callback just mutates fields:
setState(() {
_count++;
});
// (Mutating outside setState and then calling it also "works" but is a smell)
_count++;
setState(() {}); // ⚠️ works, but put the mutation INSIDE — see below
Why the callback exists: putting the mutation inside
setState(() {...})makes the intent unmistakable — "these changes are the reason I'm rebuilding" — and guarantees the change happens before the dirty flag is read. Always mutate inside it.
Two consequences fall out of this model immediately:
setStateis asynchronous in effect. The rebuild happens on the next frame, not the instant you call it. Don't expect the new widget to exist on the very next line.- It rebuilds the whole
State'sbuild()method — the entire widget subtree returned by thatbuild, not just the oneTextthat changed. Flutter is smart about reconciling cheaply, but yourbuild()function runs top to bottom again. (Remember this — it's the seed of why setState stops scaling.)
The rules that trip everyone up
setState has a few hard rules. Breaking them is the source of a huge fraction of beginner Flutter bugs.
1. Only call it inside a State object. A StatelessWidget has no setState — it has no mutable state to flag. If you need setState, you need a StatefulWidget.
2. Never call it in build(). Calling setState during a build schedules another build… during a build. You get an infinite loop or an exception.
@override
Widget build(BuildContext context) {
setState(() {}); // ❌ "setState() called during build" — never do this
return const SizedBox();
}
3. Don't call it after dispose(). If an async callback completes after the widget is gone, setState throws. Guard with mounted:
Future<void> _load() async {
final data = await api.fetch();
if (!mounted) return; // ❗ widget may have been disposed while awaiting
setState(() => _data = data);
}
4. Calling it with no real change still rebuilds. setState doesn't diff your fields; it just marks dirty. If nothing actually changed, you did a wasted rebuild.
Remember:
setStatemarks thisStatedirty — nothing above it, nothing in a sibling. State lives where you put it, and only that spot rebuilds. That locality is both its strength and, soon, its weakness.
Where setState shines
Let's be fair to it. setState is perfect for local, ephemeral UI state — data that's owned by exactly one widget and doesn't need to be shared:
- A toggle's on/off, an expand/collapse, a form field's focus.
- An animation value, a "is this card hovered" flag.
- A loading spinner local to one screen.
// Classic, correct setState: state nobody else needs.
bool _obscurePassword = true;
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
)
For this, reaching for Provider or Bloc would be over-engineering. The whole series builds toward judgment, and rule one of that judgment is: keep purely local state in setState. We'll formalize this in Part 6.
The exact moment setState stops being enough
So why a whole series? Because setState has one structural limitation that no amount of cleverness fixes:
setStatecan only rebuild the widget that owns the state. But real apps need the same state in widgets that are far apart in the tree.
Picture a shopping app. The cart count is shown in the app-bar badge (top of the tree), edited on the product page (middle), and summarized on the checkout screen (a different route entirely). With setState, where do you put _cartCount?
Wherever you put it, the other places can't see it. So you're forced into the dreaded workaround: lift the state up to a common ancestor and pass it down through every layer as constructor parameters and callbacks.
// State lifted to the top, then threaded through EVERY widget in between:
class ShopApp extends StatefulWidget {/* holds cartCount */}
// ...passed down manually, level after level:
AppBarBadge(cartCount: cartCount)
ProductPage(cartCount: cartCount, onAdd: incrementCart)
CheckoutScreen(cartCount: cartCount)
// └─ which passes it to OrderSummary(cartCount: cartCount)
// └─ which passes it to Total(cartCount: cartCount) ...
This is prop drilling (a.k.a. "lifting state up until it hurts"). The pain is real and specific:
| Problem | What it feels like |
| --- | --- |
| Boilerplate | Every intermediate widget gets a cartCount param it doesn't even use, just to pass it along. |
| Tight coupling | Adding one piece of shared state means editing a dozen unrelated widgets. |
| Over-rebuilding | setState at the top rebuilds the entire subtree, even branches that don't care about the cart. |
| Tangled ownership | "Who owns this state?" becomes genuinely hard to answer. |
This is the problem the rest of the series solves. Every tool — InheritedWidget, Provider, Riverpod, Bloc — exists to let a widget read shared state directly, without threading it through every ancestor, and to rebuild only the widgets that actually use it.
A taste of the fix (so the next part lands)
Flutter's own answer to prop drilling is a widget that lets descendants reach up the tree and grab a value directly — no constructor threading. It's called InheritedWidget, and it's the machinery underneath Provider and Riverpod.
// Sketch — full story in Part 2:
final count = CartScope.of(context).count; // reach up the tree, no props threaded
That single line — "ask the tree for the value" instead of "receive it through ten constructors" — is the conceptual leap from setState to real state management. That's exactly where Part 2 goes.
Practice Challenges
Challenge 1 — Fix the rebuild. This counter never updates on screen. Why, and how do you fix it?
class _C extends State<C> {
int n = 0;
void add() => n++;
@override
Widget build(BuildContext context) =>
TextButton(onPressed: add, child: Text('$n'));
}
Show solution
add() mutates n but never calls setState, so the State is never marked dirty and build() never re-runs. Fix:
void add() => setState(() => n++);
Challenge 2 — Spot the crash. What's wrong here, and the one-line fix?
Future<void> load() async {
final user = await api.getUser();
setState(() => _user = user);
}
Show solution
If the widget is disposed while await is pending, setState runs on a dead State and throws. Guard with mounted:
final user = await api.getUser();
if (!mounted) return;
setState(() => _user = user);
Challenge 3 — Local or shared? For each, say whether setState is the right tool: (a) whether a password field is obscured, (b) the logged-in user shown on five different screens, (c) the current page of a PageView only this widget reads.
Show solution
(a) setState — purely local UI state. (b) Not setState — shared across distant widgets; needs real state management (Parts 2–5). (c) setState — local to this widget.
Challenge 4 — Why does the whole subtree rebuild? A screen has a heavy Chart widget and a small counter, both inside one State's build(). Tapping the counter feels janky. Explain in terms of how setState works, and name a fix direction.
Show solution
setState rebuilds the entire build() of that State, so the expensive Chart is rebuilt on every counter tap even though it didn't change. Fixes: extract the counter into its own small StatefulWidget (so setState is scoped to it), make Chart const/cached so it's skipped, or use a state solution that rebuilds only the counter. (More in Part 6.)
Challenge 5 — The drilling smell. You need a theme mode (light/dark) read by the app bar, a settings toggle, and a deep child. Describe what setState forces you to do and why it's painful.
Show solution
You must lift themeMode to a common ancestor and thread it (plus a toggle callback) through every intermediate widget down to each consumer — prop drilling. It's painful because intermediate widgets carry params they don't use, adding shared state touches many files, and the top-level setState over-rebuilds. This is exactly what InheritedWidget/Provider fix (Part 2).
Questions to test yourself
Q1 (basic). What does setState actually do when you call it?
Show answer
It runs your callback (where you mutate fields), marks the State's element dirty via markNeedsBuild(), and returns. Flutter then re-runs that State's build() on the next frame. It doesn't itself paint anything.
Q2 (basic). Why must the mutation go inside the setState callback?
Show answer
To make intent clear ("these changes cause this rebuild") and guarantee the mutation happens before the dirty flag is acted on. Mutating outside and calling setState(() {}) works but is a code smell.
Q3 (intermediate). Is setState synchronous or asynchronous in effect, and why does it matter?
Show answer
The callback runs synchronously, but the rebuild is deferred to the next frame — so it's asynchronous in effect. It matters because the new widget tree doesn't exist on the next line; don't read post-rebuild results immediately after calling setState.
Q4 (intermediate). Why does setState cause "over-rebuilding," and how can you limit it?
Show answer
It rebuilds the entire build() of the State it's called on, including expensive children that didn't change. Limit it by pushing state down into small StatefulWidgets, using const to fence static subtrees, or adopting a state solution that rebuilds only the consumers.
Q5 (advanced). Explain precisely why setState doesn't scale to app-wide shared state.
Show answer
setState can only rebuild the widget that owns the state. Sharing state across distant widgets forces you to lift it to a common ancestor and prop-drill it down through every layer, causing boilerplate, tight coupling, tangled ownership, and over-rebuilding. It has no mechanism for a far-away widget to read the state directly — which is what real state management provides.
Q6 (advanced). Why does guarding async setState with mounted matter, and what is mounted?
Show answer
mounted is a State flag that's true while the State is in the tree and false after dispose(). An async callback can complete after the widget is gone; calling setState then throws. if (!mounted) return; skips the update when the widget no longer exists, preventing the crash (and a potential leak/wasted work).
Wrapping up
- State is data that changes and affects the UI; Flutter keeps
UI = f(state)true by re-runningbuild(). setStateruns your mutation, marks theStatedirty (markNeedsBuild), and lets Flutter rebuild that subtree next frame — it doesn't paint anything itself.- Respect the rules: only in a
State, never inbuild(), guard async withmounted, mutate inside the callback. - It's the right tool for local, ephemeral UI state — don't over-engineer those.
- It stops scaling the moment state must be shared across distant widgets, forcing prop drilling and over-rebuilding.
In Part 2 we meet Flutter's built-in answer to prop drilling — the mechanism every other solution is built on: InheritedWidget, and the of(context) pattern that lets any descendant read shared state directly.