Skip to content

setState & the Lifecycle

setState does not “redraw the screen.” It does one small thing: it runs your callback (which mutates fields), then marks this widget’s element as dirty. On the next frame, Flutter rebuilds every dirty element by calling build again.

class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
void _increment() {
setState(() {
_count++; // mutate state INSIDE the callback
}); // then Flutter marks this element dirty
}
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: _increment,
child: Text('count: $_count'),
);
}
}

Two rules fall out of this: mutate state inside the setState callback (so the change and the dirty-marking happen together), and know that setState schedules a rebuild for the next frame — it is not synchronous.

A State object has a well-defined lifecycle. Knowing which hook does what prevents a whole class of bugs.

flowchart TB
  create["createState()"] --> init["initState()
one-time setup"]
  init --> deps["didChangeDependencies()
after inherited deps ready"]
  deps --> build["build()
called on every rebuild"]
  build --> update["didUpdateWidget()
parent rebuilt with new config"]
  update --> build
  build --> dispose["dispose()
cleanup"]
The State lifecycle
  • initState — one-time setup: create controllers, subscribe to streams. Runs once. You cannot use context for inherited widgets here reliably.
  • didChangeDependencies — runs after initState and again whenever an inherited dependency changes. The safe place to read InheritedWidget/Provider data that setup depends on.
  • build — called on every rebuild. Must be pure and fast: no side effects, no network calls, no subscriptions. Just describe the UI from current state.
  • didUpdateWidget — the parent rebuilt and gave this State a new widget config; react to changed props (e.g. re-subscribe if an id changed).
  • dispose — clean up: dispose controllers, cancel subscriptions. Forgetting this is the classic Flutter memory leak.

setState marks this State’s element dirty, so build re-runs and produces a new subtree — but Flutter is smart about it. It diffs the new widget subtree against the old element tree and only touches what changed. Still, the whole build method runs, so a setState high in the tree that rebuilds a huge subtree is a real performance concern.

The practical lesson: push setState as low in the tree as possible. Isolate the changing bit into its own small StatefulWidget so only that tiny subtree rebuilds, not the whole page. (The performance module goes deeper on this.)

What does setState actually do?
Which lifecycle hook is the right place to dispose controllers and cancel subscriptions?
Why must the build method stay pure and side-effect-free?
How do you limit how much rebuilds when state changes?