Flutter

Flutter Widget Lifecycle Explained: From Build to Dispose

Ahmad Najar Ahmad Najar 2 min read

Every Flutter Bug Report Traces Back to the Lifecycle

Memory leaks, stale data, controllers that crash on hot reload — almost all of them come from misunderstanding when Flutter calls which lifecycle method. Here's the sequence that actually matters.

The core sequence

class ProfileScreen extends StatefulWidget {
  const ProfileScreen({super.key});
  @override
  State createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State {
  late final ScrollController _controller;

  @override
  void initState() {
    super.initState();
    _controller = ScrollController();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Called after initState, and again if an InheritedWidget above changes
  }

  @override
  Widget build(BuildContext context) {
    return ListView(controller: _controller, children: [...]);
  }

  @override
  void didUpdateWidget(covariant ProfileScreen oldWidget) {
    super.didUpdateWidget(oldWidget);
    // Called when the parent rebuilds this widget with new configuration
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

What each stage is actually for

  • initState — runs once, before the first build. Set up controllers, subscriptions, animation controllers here. Never call Provider.of(context) here without listen: false — the widget tree isn't fully established yet.
  • didChangeDependencies — runs after initState and again whenever an inherited widget it depends on changes (theme, locale, MediaQuery). This is where you should read InheritedWidget/Theme.of(context) values that need to react to changes.
  • build — called potentially many times per second. Anything expensive here (network calls, heavy computation) is a performance bug waiting to happen.
  • didUpdateWidget — fires when the parent passes new configuration to an already-mounted widget (e.g. a new userId prop). This is where you re-fetch data tied to changed parameters — a very common miss that causes screens to silently show stale data.
  • dispose — release everything: controllers, stream subscriptions, animation controllers. Forgetting this is the #1 cause of "setState called after dispose" crashes.

The classic bug: setState after dispose

void _loadData() async {
  final data = await api.fetchProfile();
  if (!mounted) return; // guard — the widget may have been disposed while awaiting
  setState(() => _profile = data);
}

Any async gap (an await) is a point where the widget could be disposed before it resumes. Always check mounted after an await, before calling setState.

Practical checklist

  • Every controller created in initState gets disposed in dispose — no exceptions.
  • Data tied to widget parameters gets refreshed in didUpdateWidget, not assumed to be re-fetched automatically.
  • Guard every post-await setState with a mounted check.
  • Keep build pure and cheap — it will run more often than you expect.
Flutter