Skip to content
ExponaBLOG
FLUTTER · DAY 010 / 100

The widget lifecycle: Flutter best practices explained

When Flutter calls what.

Expona Admin7 min read

You build a Flutter app, tap a button, and suddenly a controller throws an error because it was already disposed. Or you initialize a listener in initState, but never clean it up, and memory leaks silently compound. These aren't edge cases — they're the result of misunderstanding the widget lifecycle. Understanding when Flutter calls what is fundamental to writing robust, memory-efficient code, and the widget lifecycle: Flutter best practices explained in this guide will help you avoid the traps that catch most developers.

Understanding the widget lifecycle

Every stateful widget in Flutter follows a predictable sequence of method calls from creation to destruction. This lifecycle determines when setup happens, when rebuilds occur, and crucially, when cleanup must happen. If you're managing resources — controllers, streams, listeners, timers — you need to know where each phase fits.

Think of the lifecycle as a contract between your widget and the Flutter framework. Follow it correctly, and your app runs smoothly. Break it, and you'll spend hours debugging memory leaks or null pointer exceptions.

The core sequence is: createState → initState → didChangeDependencies → build → dispose. Some phases run once, others repeatedly. Some are optional, others essential.

The complete state lifecycle flow

Here's what happens, step by step:

  1. createState: The framework instantiates your State object. This is internal and you rarely override it.
  2. initState: Called once after the State object is created. This is your setup phase.
  3. didChangeDependencies: Called after initState and whenever an inherited widget changes.
  4. build: Called after didChangeDependencies and whenever setState is called or dependencies change.
  5. didUpdateWidget: Called when the parent widget rebuilds with new configuration (only if the widget is kept in the tree).
  6. dispose: Called when the widget is permanently removed from the tree. This is your cleanup phase.

Not every widget uses every method. A simple stateful widget might only use initState, build, and dispose. But understanding the full sequence helps you debug and optimize.

initState: One-time setup

initState is called exactly once, immediately after the State object is created and before the first build. It's the right place to initialize controllers, subscribe to streams, fetch initial data, and set up listeners.

Here's a real-world example: a chat screen that listens to incoming messages.

dart
class ChatScreen extends StatefulWidget {
  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  late StreamSubscription<Message> _messageSubscription;
  late TextEditingController _textController;
  final List<Message> _messages = [];

  @override
  void initState() {
    super.initState();
    _textController = TextEditingController();
    
    // Subscribe to incoming messages
    _messageSubscription = messageService.messageStream.listen(
      (message) {
        setState(() {
          _messages.add(message);
        });
      },
      onError: (error) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error: $error')),
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: _messages.map((msg) => Text(msg.content)).toList(),
    );
  }
}

Notice that we call super.initState() first. This is required. We initialize the text controller and subscribe to the message stream. Both need cleanup later.

A common mistake: putting expensive operations like HTTP requests in initState without proper error handling. Always wrap network calls in try-catch and provide fallback UI.

didChangeDependencies: Reacting to inherited widgets

didChangeDependencies is called after initState and whenever an inherited widget that this widget depends on changes. It's less commonly used than initState, but essential for certain patterns.

Use it when your widget needs to respond to changes in theme, locale, user authentication state, or other inherited values.

dart
class ThemedCounter extends StatefulWidget {
  @override
  State<ThemedCounter> createState() => _ThemedCounterState();
}

class _ThemedCounterState extends State<ThemedCounter> {
  int _count = 0;
  late ThemeData _cachedTheme;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Cache the theme when it changes
    _cachedTheme = Theme.of(context);
  }

  @override
  Widget build(BuildContext context) {
    return Text('Count: $_count', style: _cachedTheme.textTheme.headlineLarge);
  }
}

Why cache the theme? Because didChangeDependencies can be called multiple times. By caching, you avoid redundant lookups in build.

build: Called many times

build is called after initState and didChangeDependencies, and then every time setState is triggered or dependencies change. It must be pure: no side effects, no I/O, no state mutations.

dart
@override
Widget build(BuildContext context) {
  // GOOD: Returns UI based on state
  return Column(
    children: [
      Text('Messages: ${_messages.length}'),
      Expanded(child: ListView(children: _buildMessageList())),
      _buildInputField(),
    ],
  );
}

// GOOD: Helper that builds UI
List<Widget> _buildMessageList() {
  return _messages.map((msg) => MessageTile(message: msg)).toList();
}

// BAD: Side effects in build
@override
Widget build(BuildContext context) {
  messageService.fetchMessages(); // DON'T DO THIS
  return Container();
}

If you need to run side effects based on state changes, use initState or didUpdateWidget. The build method must stay pure.

didUpdateWidget: React to new configuration from the parent

didUpdateWidget is called when the parent widget rebuilds and passes new configuration to this widget. It's your chance to detect changes and respond — for example, resetting a timer or restarting a stream.

This is common in reusable components that take parameters.

dart
class TimerWidget extends StatefulWidget {
  final Duration duration;

  TimerWidget({required this.duration});

  @override
  State<TimerWidget> createState() => _TimerWidgetState();
}

class _TimerWidgetState extends State<TimerWidget> {
  late Timer _timer;
  int _remaining = 0;

  @override
  void initState() {
    super.initState();
    _remaining = widget.duration.inSeconds;
    _startTimer();
  }

  @override
  void didUpdateWidget(TimerWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    // If parent passed a new duration, reset the timer
    if (oldWidget.duration != widget.duration) {
      _timer.cancel();
      _remaining = widget.duration.inSeconds;
      _startTimer();
    }
  }

  void _startTimer() {
    _timer = Timer.periodic(Duration(seconds: 1), (_) {
      if (_remaining > 0) {
        setState(() => _remaining--);
      } else {
        _timer.cancel();
      }
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text('$_remaining seconds remaining');
  }
}

The parent might rebuild and pass TimerWidget(duration: Duration(minutes: 2)). Without didUpdateWidget, the old timer would keep running with the old duration.

dispose: Clean up resources

dispose is called when the State object is permanently removed from the widget tree. It's your last chance to clean up: close streams, cancel timers, dispose controllers, remove listeners. If you don't, you'll leak memory and potentially crash.

Always call super.dispose() last.

dart
@override
void dispose() {
  // Cancel subscriptions
  _messageSubscription.cancel();
  
  // Dispose controllers
  _textController.dispose();
  
  // Cancel timers
  _timer.cancel();
  
  // Remove listeners
  _focusNode.removeListener(_onFocusChanged);
  
  super.dispose();
}

A complete example from earlier showed this pattern. The chat screen initialized both a TextEditingController and a stream subscription in initState. Both must be cleaned up in dispose.

Common mistakes

Forgetting to call super.initState() or super.dispose(): This breaks the framework's internal state management. Always call the super method first in initState and last in dispose.

Not disposing controllers or streams: A StreamController or TextEditingController that's never disposed leaks memory. After a few navigation cycles, this adds up. Always dispose in the corresponding dispose method.

Running expensive operations in build: Network calls, file I/O, or heavy computation in build will cause lag and jank. Move these to initState or use FutureBuilder/StreamBuilder.

Ignoring didUpdateWidget: If your widget accepts constructor parameters that control its behavior (like a list ID, user ID, or duration), and those change, you must respond in didUpdateWidget. Otherwise, your widget's state won't match the new configuration.

Calling setState in dispose: If you call setState after dispose, Flutter will throw an error. Make sure any async callbacks (API responses, stream events) check if the widget is still mounted before calling setState.

dart
// Prevent setState after dispose
void _handleAsyncResponse(data) {
  if (mounted) {
    setState(() => _data = data);
  }
}

Key takeaways

  • initState runs once after the State is created. Use it to initialize controllers, start listeners, and set up subscriptions.
  • build runs many times and must be pure — no side effects, only UI construction.
  • didChangeDependencies runs after initState and when inherited widgets change. Use it to react to theme, locale, or authentication changes.
  • didUpdateWidget runs when the parent passes new configuration. Check for changes and reset state if needed.
  • dispose runs once when the widget is removed. Close all streams, cancel timers, and dispose all controllers.
  • Always call super.initState() first and super.dispose() last.
  • Check mounted before calling setState in async callbacks to avoid errors after dispose.
  • Understand the full lifecycle to debug memory leaks, prevent null pointer exceptions, and write responsive apps.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles