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:
- createState: The framework instantiates your State object. This is internal and you rarely override it.
- initState: Called once after the State object is created. This is your setup phase.
- didChangeDependencies: Called after
initStateand whenever an inherited widget changes. - build: Called after
didChangeDependenciesand wheneversetStateis called or dependencies change. - didUpdateWidget: Called when the parent widget rebuilds with new configuration (only if the widget is kept in the tree).
- 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.
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.
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.
@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.
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.
@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.
// Prevent setState after dispose
void _handleAsyncResponse(data) {
if (mounted) {
setState(() => _data = data);
}
}Key takeaways
initStateruns once after the State is created. Use it to initialize controllers, start listeners, and set up subscriptions.buildruns many times and must be pure — no side effects, only UI construction.didChangeDependenciesruns afterinitStateand when inherited widgets change. Use it to react to theme, locale, or authentication changes.didUpdateWidgetruns when the parent passes new configuration. Check for changes and reset state if needed.disposeruns once when the widget is removed. Close all streams, cancel timers, and dispose all controllers.- Always call
super.initState()first andsuper.dispose()last. - Check
mountedbefore callingsetStatein async callbacks to avoid errors after dispose. - Understand the full lifecycle to debug memory leaks, prevent null pointer exceptions, and write responsive apps.






Comments
Loading comments…