← Back to blog
Flutter Internals · Part 4 of 7
September 23, 20269 min read

The Widget Lifecycle: initState, didChangeDependencies, dispose & Friends

FlutterDartInternals

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. (StatelessWidget has no lifecycle beyond build — this is all about State.)


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.of that you want to track) — that's didChangeDependencies (Part 3). A one-off read can be okay, but a subscribing read belongs later.
  • Don't call setState here — 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: initState is too early and too one-shot for inherited values. didChangeDependencies gives you a hook that fires once at startup and again whenever something you depend on (via of(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 initState based on widget.someProp, but the parent later passes a new someProp. initState won't run again (same State!), so without didUpdateWidget you'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 a GlobalKey re-parent from Part 2). Rarely overridden.
  • dispose() — the Element is gone for good. Release every resource you acquired. This is the counterpart to initState:
@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 in initState (or didUpdateWidget) must be torn down here, or you leak — exactly the retained-reference leaks from the GC part and State Mgmt Part 1. Call super.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

createStateinitStatedidChangeDependenciesbuild.

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 State lifecycle: createStateinitStatedidChangeDependenciesbuild (↻ setState/didUpdateWidget/dep-change) → deactivatedispose.
  • initState (once): setup, super first. dispose (once): tear down everything, super last.
  • didChangeDependencies: react to inherited changes (Theme/Locale/providers). didUpdateWidget: react to new widget props (because the same State is reused per canUpdate).
  • Forgetting dispose (or reconciling props in didUpdateWidget) causes leaks and stale subscriptions; guard async work with mounted.
  • StatelessWidget has none of this — only build.

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.