← All posts

7 min read

The Flutter Widget Lifecycle Explained: initState to dispose

The Flutter widget lifecycle explained step by step: initState, didChangeDependencies, didUpdateWidget, setState, dispose, the mounted check and common bugs.

  • Flutter
  • Widget Lifecycle
  • Fundamentals
Cover illustration for The Flutter Widget Lifecycle Explained: initState to dispose

“setState() called after dispose()” is probably the most common red text I see in Flutter logs. It’s almost never a hard bug to fix. It’s a sign that someone wrote a State class without a clear picture of when each lifecycle method runs.

So let’s walk through that picture, one method at a time, in the order Flutter calls them. Then we’ll look at the bugs that come from getting the order wrong.

Stateless vs stateful, briefly

A StatelessWidget has one lifecycle method you care about: build. It’s a function of its constructor arguments and whatever inherited widgets it reads. When either changes, Flutter calls build again. That’s it.

A StatefulWidget is split in two. The widget itself is still immutable and cheap, and gets thrown away and recreated on every parent rebuild. The State object is the long-lived part. It survives those rebuilds, holds your mutable data, and owns anything that needs cleanup: controllers, subscriptions, timers.

That split is the whole reason the lifecycle exists. Widgets are configuration. State is the thing that lives.

The lifecycle, in order

Here’s a small widget that touches every method worth knowing. It listens to a stream of messages for a chat room, given by roomId.

class ChatRoomView extends StatefulWidget {
  const ChatRoomView({super.key, required this.roomId, required this.service});

  final String roomId;
  final ChatService service;

  @override
  State<ChatRoomView> createState() => _ChatRoomViewState();
}

class _ChatRoomViewState extends State<ChatRoomView> {
  final _scrollController = ScrollController();
  StreamSubscription<List<Message>>? _subscription;
  List<Message> _messages = const [];
  late TextStyle _bodyStyle;

  @override
  void initState() {
    super.initState();
    _listenTo(widget.roomId);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _bodyStyle = Theme.of(context).textTheme.bodyMedium!;
  }

  @override
  void didUpdateWidget(ChatRoomView oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.roomId != widget.roomId) {
      _subscription?.cancel();
      _listenTo(widget.roomId);
    }
  }

  void _listenTo(String roomId) {
    _subscription = widget.service.messages(roomId).listen((messages) {
      setState(() => _messages = messages);
    });
  }

  @override
  void dispose() {
    _subscription?.cancel();
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: _messages.length,
      itemBuilder: (context, i) => Text(_messages[i].body, style: _bodyStyle),
    );
  }
}

Now the order Flutter calls things in.

createState

Called once, when the widget is first inserted into the tree. Return a new State and do nothing else. Don’t pass data into the State constructor; read it from widget instead, because widget gets updated and your constructor arguments don’t.

initState

Called exactly once, right after the State is attached to the tree. This is where you create controllers, start subscriptions, and kick off the first load. Always call super.initState() first.

The catch: context exists here, but you can’t use it for anything that depends on inherited widgets. Theme.of(context), MediaQuery.of(context) and friends register a dependency, and that isn’t allowed until initState has finished. You’ll get an assertion error in debug mode.

didChangeDependencies

Called immediately after initState, and again any time an inherited widget this State depends on changes, such as the theme, locale or media query. This is the right place to read those values when you need them outside build, as with _bodyStyle above.

Most widgets don’t need this method at all. If you only use Theme.of(context) inside build, Flutter already rebuilds you when the theme changes.

build

Called after didChangeDependencies, after every setState, after every didUpdateWidget, and whenever a dependency changes. It can run many times per second during an animation. Treat it as a pure function: read state, return widgets, nothing else.

didUpdateWidget

Called when the parent rebuilds and hands this State a new widget of the same type and key. oldWidget is the previous configuration and widget is the new one. If anything you subscribed to in initState came from the widget’s fields, this is where you compare and resubscribe, as the roomId check does.

Forgetting didUpdateWidget is a quiet bug. The screen keeps showing room A’s messages after the user navigated to room B, because initState never runs a second time.

setState

Not a lifecycle callback, but it drives everything else. It runs your closure synchronously, marks the element dirty, and schedules a build for the next frame. Keep the closure small: mutate fields and leave the expensive work outside.

deactivate

Called when the State is removed from the tree. Usually dispose follows at the end of the frame. The exception is when a GlobalKey moves the widget somewhere else in the same frame. Then activate is called and life goes on. I almost never override deactivate, and you probably won’t either.

dispose

Called once, when the State is gone for good. Cancel subscriptions, close stream controllers, dispose every controller you created, stop timers. Call super.dispose() last, the mirror of initState.

One rule of thumb that has saved me a lot of grief: anything you create in initState gets cleaned up in dispose. If you read the two methods side by side and they don’t mirror each other, something is leaking.

The mounted check after async gaps

Here’s the classic source of “setState() called after dispose()”:

Future<void> _save() async {
  setState(() => _saving = true);
  await widget.repository.save(_draft);
  setState(() => _saving = false); // the user may have left the screen
  Navigator.of(context).pop();
}

During that await, the user can press back. The State gets disposed, the future completes anyway, and setState throws. Using context after the gap is worse, because it may point at an element that’s no longer in the tree.

The fix is to check mounted after every await before touching setState or context:

Future<void> _save() async {
  setState(() => _saving = true);
  await widget.repository.save(_draft);
  if (!mounted) return;
  setState(() => _saving = false);
  Navigator.of(context).pop();
}

Inside a State, use the mounted property. In a helper function that only has a BuildContext, use context.mounted. Turn on the use_build_context_synchronously lint; it catches most of these at analysis time. I also cover the error-handling side of this in error handling in Flutter apps.

Where to start listeners, where to cancel them

A simple table I give to people joining a project:

Thing Create / start Update Clean up
Controllers (text, scroll, animation) initState rarely dispose
Stream subscriptions from widget fields initState didUpdateWidget dispose
Values from inherited widgets didChangeDependencies or build automatic nothing
Timers initState or on demand cancel and restart dispose
One-off post-frame work (dialogs, scrolling) addPostFrameCallback in initState n/a n/a

For AnimationController, mix in SingleTickerProviderStateMixin and pass vsync: this. The ticker stops when you dispose the controller, which is one more reason never to skip dispose.

If you use Riverpod or BLoC, a lot of this disappears into providers and blocs that manage their own lifetimes. That’s a big part of why I like them; I compare the two in Riverpod vs BLoC. But controllers for text fields and animations still live in widgets, so the lifecycle never fully goes away.

Common bugs I keep finding

Using context-dependent APIs in initState. MediaQuery.of(context) in initState fails an assertion. Showing a dialog or snackbar from initState fails too, because the first frame hasn’t been built yet. Move inherited reads to didChangeDependencies or build, and defer UI actions to after the first frame:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (!mounted) return;
    showDialog<void>(
      context: context,
      builder: (_) => const WhatsNewDialog(),
    );
  });
}

Forgetting dispose. A TextEditingController that’s never disposed is a small leak. A stream subscription that’s never cancelled is a bigger one, and it will keep calling setState on a dead State until something throws. Run the DevTools memory view on a screen you open and close twenty times; leaks show up fast.

Heavy work in build. Sorting a list of five thousand items, parsing JSON, or creating a new Future in build means doing it on every frame that rebuilds the widget. Creating a future in build and handing it to a FutureBuilder is especially bad, because each rebuild restarts the request. Create the future once in initState and store it in a field. More on this in fixing jank in Flutter apps.

Creating controllers in build. TextField(controller: TextEditingController()) inside build throws away the user’s text on every rebuild. Controllers belong in fields, created in initState.

Calling setState during build. If a value can be computed from existing state, compute it in build. Don’t store it with setState from inside build; Flutter will complain, and rightly so.

Takeaways

  • StatefulWidget is configuration; State is the long-lived object with a lifecycle.
  • Create in initState, react to new widget config in didUpdateWidget, clean up in dispose. The first and last should mirror each other.
  • Read inherited widgets in didChangeDependencies or build, never in initState.
  • After every await, check mounted before calling setState or using context.
  • Keep build cheap and pure. No futures, no controllers, no heavy computation.

Once the order of these calls clicks, most of the scary red text in your logs turns out to be the same three mistakes.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).