The Widget Lifecycle
This is Part 4 of the Flutter Internals series. A StatefulWidget's State isn't a static thing — it's born, it prepares, it reacts to changes, and it dies. Each of those moments has a named hook, and using the wrong hook (or forgetting one) causes a whole genre of bugs: leaked controllers, stale subscriptions, "setState after dispose," animations that don't restart.
By now you have the foundation to understand why each hook exists. The lifecycle is just the Element (Part 1) managing its State through reconciliation, with didChangeDependencies tied to the inherited lookups from Part 3 and didUpdateWidget tied to the canUpdate reuse from Part 1. Let's walk a State from cradle to grave.
Builds on Parts 1–3. Flutter 3.38 / Dart 3.12. (
StatelessWidgethas no lifecycle beyondbuild— this is all aboutState.)
The whole life, in order
Analogy — an actor's run in a play.
createState() → the actor is cast (State object created)
│
initState() → one-time prep before opening night
│
didChangeDependencies() → learns the venue (inherited values); re-runs if venue changes
│
build() ◄──────────────┐ performs (called many times)
│ │
├─ setState() ────────┤ re-perform with new state
├─ didUpdateWidget() ─┤ gets script revisions (parent passed new config)
├─ didChangeDependencies() ┘ inherited dependency changed
│
deactivate() → leaves the stage (removed from tree; might return)
│
dispose() → retires for good (clean up everything)
Two hooks fire once (initState, dispose), one fires once-then-on-change (didChangeDependencies), and three can fire many times (build, didUpdateWidget, and build via setState). Let's take them one by one.
createState() — birth
Called by the framework when the Element for your StatefulWidget is first mounted. You just return your State:
@override
State<Counter> createState() => _CounterState();
That State object now lives in the StatefulElement (Part 1) and survives every rebuild until the Element is disposed. Everything below happens to that one object.
initState() — one-time setup
Runs exactly once, right after the State is created and inserted, before the first build. This is where you do one-time initialization:
@override
void initState() {
super.initState(); // ✅ call super FIRST
_controller = AnimationController(vsync: this, duration: _kDur);
_subscription = widget.stream.listen(_onData);
_scrollController = ScrollController();
}
Rules:
- Call
super.initState()first. - Safe to use
widget(your config) and create controllers/subscriptions. - Don't do inherited lookups that must react to change here (
Theme.of,Provider.ofthat you want to track) — that'sdidChangeDependencies(Part 3). A one-off read can be okay, but a subscribing read belongs later. - Don't call
setStatehere — the first build is already coming.
didChangeDependencies() — venue, and venue changes
Runs right after initState, and again every time an inherited dependency this State uses changes (Part 3's InheritedWidgets). It's the correct home for reacting to inherited values:
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Re-reads when the inherited Locale/Theme/Provider changes.
final locale = Localizations.localeOf(context);
_formatter = DateFormat.yMMMd(locale.toString());
}
Why it exists:
initStateis too early and too one-shot for inherited values.didChangeDependenciesgives you a hook that fires once at startup and again whenever something you depend on (viaof(context)) updates — perfect for re-deriving cached values from inherited state.
build() — the performance, many times
The one you know. Called after initState/didChangeDependencies, and again on every setState, didUpdateWidget, and dependency change. It must be pure and fast — no side-effects, no controller creation, no async kickoff (that's initState). (Efficiency and State Mgmt Part 1.)
didUpdateWidget(oldWidget) — the parent handed you new config
This is the subtle, powerful one, and it's pure Part 1. Recall: when a parent rebuilds and canUpdate says "reuse" (same type+key), the same State is kept but given a new widget instance. didUpdateWidget is where you react to that — comparing the old config to the new:
@override
void didUpdateWidget(covariant VideoView oldWidget) {
super.didUpdateWidget(oldWidget);
// The parent may have passed a DIFFERENT url into the SAME State.
if (oldWidget.url != widget.url) {
_controller.dispose(); // tear down old
_controller = VideoController(widget.url)..initialize(); // rebuild for new
}
}
The classic bug it prevents: you set up a subscription/controller in
initStatebased onwidget.someProp, but the parent later passes a newsomeProp.initStatewon't run again (sameState!), so withoutdidUpdateWidgetyou're still listening to the old prop. Always reconcile prop-dependent resources here.
| Hook | Fires when | Use for |
| --- | --- | --- |
| didChangeDependencies | inherited (of(context)) value changed | re-derive from Theme/Locale/providers |
| didUpdateWidget | parent rebuilt with new widget config | re-derive from changed widget.* props |
These two are constantly confused. The tell: inherited value → didChangeDependencies; my own widget's props → didUpdateWidget.
deactivate() and dispose() — exit
deactivate()— the Element is removed from the tree. It might be reinserted elsewhere in the same frame (e.g. via aGlobalKeyre-parent from Part 2). Rarely overridden.dispose()— the Element is gone for good. Release every resource you acquired. This is the counterpart toinitState:
@override
void dispose() {
_controller.dispose(); // AnimationController
_subscription.cancel(); // StreamSubscription
_scrollController.dispose(); // ScrollController
super.dispose(); // ✅ call super LAST
}
The dispose discipline: anything with a
dispose()/cancel()/close()that you created ininitState(ordidUpdateWidget) must be torn down here, or you leak — exactly the retained-reference leaks from the GC part and State Mgmt Part 1. Callsuper.dispose()last.
After dispose, mounted is false — which is why async callbacks must guard with mounted/context.mounted (Part 3).
One more: reassemble() (hot reload only)
During development, hot reload (Fundamentals: hot reload) calls reassemble() so you can re-run dev-time setup. It never fires in production — don't rely on it for app logic.
A complete, annotated example
class TickerCard extends StatefulWidget {
const TickerCard({super.key, required this.symbol});
final String symbol;
@override
State<TickerCard> createState() => _TickerCardState();
}
class _TickerCardState extends State<TickerCard> {
late StreamSubscription<double> _sub;
double _price = 0;
@override
void initState() {
super.initState();
_sub = priceStream(widget.symbol).listen(_onPrice); // setup for INITIAL symbol
}
@override
void didUpdateWidget(TickerCard old) {
super.didUpdateWidget(old);
if (old.symbol != widget.symbol) { // parent passed a new symbol
_sub.cancel(); // stop old
_sub = priceStream(widget.symbol).listen(_onPrice); // start new
}
}
void _onPrice(double p) {
if (!mounted) return; // guard async (Part 3)
setState(() => _price = p);
}
@override
void dispose() {
_sub.cancel(); // clean up (no leak)
super.dispose();
}
@override
Widget build(BuildContext context) =>
Text('${widget.symbol}: $_price'); // pure, fast
}
Every hook earns its place: initState sets up, didUpdateWidget reconciles a changed prop, _onPrice guards with mounted, dispose cleans up. This is production-grade State discipline.
Practice Challenges
Challenge 1 — Order them. Put these in firing order for a fresh widget: build, initState, didChangeDependencies, createState.
Show solution
createState → initState → didChangeDependencies → build.
Challenge 2 — Which hook? A widget caches a DateFormat derived from the inherited Locale. Where do you build it so it updates when the locale changes?
Show solution
didChangeDependencies — it runs after initState and again whenever an inherited dependency (the Locale) changes, so the formatter stays in sync. (Part 3)
Challenge 3 — The stale subscription. A State subscribes to widget.stream in initState. The parent later rebuilds passing a different stream. What breaks, and the fix?
Show solution
initState doesn't run again (same State is reused per canUpdate), so it's still listening to the old stream. Fix in didUpdateWidget:
if (old.stream != widget.stream) { _sub.cancel(); _sub = widget.stream.listen(_on); }
Challenge 4 — Leak hunt. This State creates an AnimationController in initState but the screen "leaks" on every visit. What's missing?
Show solution
A dispose that disposes the controller. Without it, the controller (and the State it ties to the ticker) is never released — a retained-reference leak (GC part):
@override
void dispose() { _controller.dispose(); super.dispose(); }
Challenge 5 — super order. State the rule for calling super in initState vs dispose.
Show solution
Call super.initState() first (before your setup) and super.dispose() last (after your cleanup). The symmetry mirrors construction/teardown order.
Questions to test yourself
Q1 (basic). Which lifecycle hooks fire exactly once?
Show answer
initState (once at start) and dispose (once at end). createState also happens once (it creates the State). didChangeDependencies fires once then again on dependency changes; build/didUpdateWidget fire many times.
Q2 (basic). What goes in initState vs build?
Show answer
initState: one-time setup (controllers, subscriptions, initial values). build: pure, fast UI description from current state — no side-effects, no resource creation, runs many times.
Q3 (intermediate). didChangeDependencies vs didUpdateWidget — when does each fire?
Show answer
didChangeDependencies fires after initState and whenever an inherited dependency (something read via of(context)) changes. didUpdateWidget(old) fires when the parent rebuilds with a new widget config (same State reused). Inherited value → former; my own widget.* props → latter.
Q4 (intermediate). Why doesn't initState re-run when a parent passes new props, and what's the consequence?
Show answer
Because canUpdate (Part 1) keeps the same State and just hands it a new widget — the Element isn't recreated. Consequence: prop-dependent setup done in initState goes stale; you must reconcile it in didUpdateWidget.
Q5 (advanced). What must dispose do, and why does forgetting it cause a leak?
Show answer
Release every resource acquired in initState/didUpdateWidget — controllers (dispose()), subscriptions (cancel()), timers, listeners. Forgetting leaves those alive, keeping the State (and its subtree) reachable so the GC can't reclaim it — a retained-reference leak (GC part).
Q6 (advanced). After dispose, what is mounted, and how does that connect to async safety?
Show answer
mounted becomes false. Async callbacks that complete after disposal must check if (!mounted) return; (or context.mounted) before calling setState/using context, or they throw "setState after dispose"/"deactivated widget" (Part 3).
Wrapping up
- The
Statelifecycle:createState→initState→didChangeDependencies→build(↻setState/didUpdateWidget/dep-change) →deactivate→dispose. initState(once): setup,superfirst.dispose(once): tear down everything,superlast.didChangeDependencies: react to inherited changes (Theme/Locale/providers).didUpdateWidget: react to new widget props (because the sameStateis reused percanUpdate).- Forgetting
dispose(or reconciling props indidUpdateWidget) causes leaks and stale subscriptions; guard async work withmounted. StatelessWidgethas none of this — onlybuild.
In Part 5 we go beyond the basic scroll view into Flutter's scrolling engine: Slivers and CustomScrollView — what a sliver actually is, the viewport protocol, and how to combine app bars, grids, and lists in one scroll.