Flutter
Flutter Widget Lifecycle: From initState to dispose
Understanding the StatefulWidget lifecycle is one of those make-or-break skills in Flutter development. Get it right, and your screens are fast, stable, and leak-free. Get it wrong, and you end up chasing phantom crashes at 2 AM. This article walks through every lifecycle method in detail, with real code and the kind of advice I wish someone had given me earlier.
The Lifecycle at a Glance
Before diving into each method, here is the full flow a StatefulWidget goes through from birth to death:
createState()
|
v
initState()
|
v
didChangeDependencies()
|
v
build() <------------------+
| |
v |
[Widget lives on screen] |
| |
v |
didUpdateWidget() ---------+
|
v (or setState() triggers rebuild via build())
|
deactivate()
|
v
dispose()Every StatefulWidget instance follows this path. The key insight is that build can be called many times, while initState and dispose are called exactly once.
Core Lifecycle Methods
createState()
This is the entry point. Flutter calls it on your StatefulWidget to produce the State object. It runs once and should contain no logic beyond returning the State instance.
class ProfileScreen extends StatefulWidget {
final String userId;
const ProfileScreen({super.key, required this.userId});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}initState()
Called once, immediately after the State object is created. This is where you set up controllers, start animations, kick off one-time data fetches, and subscribe to streams.
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_scrollController = ScrollController();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_loadUserProfile();
}Rules:
- Always call
super.initState()first. - You cannot use
contextto look up inherited widgets here because the widget is not yet fully mounted in the tree. UsedidChangeDependenciesfor that. - Keep it synchronous. If you need async work, call an async method from here but do not make
initStateitself async.
didChangeDependencies()
Called immediately after initState, and then again whenever an InheritedWidget that this State depends on changes. This is the correct place to read from Theme.of(context), MediaQuery.of(context), or any Provider/InheritedWidget lookup that you need for initialization.
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Safe to use context here
final theme = Theme.of(context);
_backgroundColor = theme.colorScheme.surface;
// If you depend on a provider for initial data
final locale = Localizations.localeOf(context);
if (_currentLocale != locale) {
_currentLocale = locale;
_reloadLocalizedContent();
}
}A mistake I see frequently in code reviews: developers put MediaQuery.of(context) calls inside initState and wonder why they get errors. The widget tree has not finished mounting at that point. Move those lookups here.
build()
The most-called method in the entire lifecycle. Flutter invokes build whenever state changes, when the parent rebuilds, or when an InheritedWidget dependency changes. It must be pure and fast: no side effects, no heavy computation, no network calls.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_userName)),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
controller: _scrollController,
itemCount: _items.length,
itemBuilder: (context, index) => ItemCard(item: _items[index]),
),
);
}Rules:
- Never call
setStateinsidebuild. - Never perform I/O or network requests here.
- If you are doing expensive computations, cache the result in a field and update it in
setState.
didUpdateWidget()
Called when the parent widget rebuilds and passes new configuration to this widget. The framework provides the old widget so you can compare and decide whether to react.
@override
void didUpdateWidget(covariant ProfileScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
// The user changed — reload data
_loadUserProfile();
}
}This is the place to restart animations, re-fetch data, or update controllers when the widget receives new parameters from its parent.
deactivate()
Called when the State is removed from the tree. This can happen temporarily if the widget moves to a different part of the tree (e.g., via GlobalKey). In most cases you will not need to override this, but it exists for advanced use cases.
@override
void deactivate() {
// Clean up anything tied to the widget's position in the tree
super.deactivate();
}dispose()
Called once when the State object is permanently removed. This is your last chance to release resources. Forgetting to clean up here is the number one source of memory leaks in Flutter apps.
@override
void dispose() {
_tabController.dispose();
_scrollController.dispose();
_animationController.dispose();
_subscription?.cancel();
_focusNode.dispose();
super.dispose();
}Rules:
- Always call
super.dispose()last. - Never call
setStateafterdisposehas run. This is the source of the dreaded "setState() called after dispose()" error.
Complete Example: All Lifecycle Methods Together
class LiveDataScreen extends StatefulWidget {
final String channelId;
const LiveDataScreen({super.key, required this.channelId});
@override
State<LiveDataScreen> createState() => _LiveDataScreenState();
}
class _LiveDataScreenState extends State<LiveDataScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
late final ScrollController _scrollController;
StreamSubscription<DataEvent>? _dataSubscription;
List<DataEvent> _events = [];
bool _isLoading = true;
bool _mounted = true;
// 1. initState — one-time setup
@override
void initState() {
super.initState();
_animController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_scrollController = ScrollController();
_subscribeToChannel(widget.channelId);
}
// 2. didChangeDependencies — context-dependent init
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Access inherited widgets safely here
final brightness = Theme.of(context).brightness;
_animController.duration = brightness == Brightness.dark
? const Duration(milliseconds: 600)
: const Duration(milliseconds: 400);
}
// 3. build — pure UI description
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Channel: ${widget.channelId}')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
controller: _scrollController,
itemCount: _events.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(_events[index].message),
subtitle: Text(_events[index].timestamp.toString()),
);
},
),
);
}
// 4. didUpdateWidget — react to parent changes
@override
void didUpdateWidget(covariant LiveDataScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.channelId != widget.channelId) {
_dataSubscription?.cancel();
_events = [];
_subscribeToChannel(widget.channelId);
}
}
// 5. deactivate — tree removal (temporary)
@override
void deactivate() {
super.deactivate();
}
// 6. dispose — permanent cleanup
@override
void dispose() {
_mounted = false;
_dataSubscription?.cancel();
_animController.dispose();
_scrollController.dispose();
super.dispose();
}
// Helper
void _subscribeToChannel(String channelId) {
_dataSubscription = DataService.subscribe(channelId).listen(
(event) {
if (!_mounted) return;
setState(() {
_events.add(event);
_isLoading = false;
});
},
onError: (error) {
debugPrint('Stream error: $error');
},
);
}
}Memory Leak Prevention
Memory leaks in Flutter almost always boil down to one thing: something holds a reference to the State object after it has been disposed. Here are the most common offenders and how to fix them.
Leaking StreamSubscriptions
// BAD: subscription never cancelled
@override
void initState() {
super.initState();
FirebaseFirestore.instance
.collection('messages')
.snapshots()
.listen((snapshot) {
setState(() {
_messages = snapshot.docs;
});
});
}
// GOOD: store the subscription and cancel it
late final StreamSubscription _messagesSub;
@override
void initState() {
super.initState();
_messagesSub = FirebaseFirestore.instance
.collection('messages')
.snapshots()
.listen((snapshot) {
setState(() {
_messages = snapshot.docs;
});
});
}
@override
void dispose() {
_messagesSub.cancel();
super.dispose();
}Leaking AnimationControllers
Every AnimationController allocates ticker resources. If you forget to dispose it, the ticker keeps firing frames even though the widget is gone.
// BAD: controller leaks
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: Durations.medium1);
_ctrl.repeat();
}
// GOOD: dispose the controller
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}Leaking TextEditingControllers and FocusNodes
These are easy to forget because they seem lightweight, but they register listeners internally.
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_emailFocusNode.dispose();
_passwordFocusNode.dispose();
super.dispose();
}The Async Gap Problem
A common source of "setState called after dispose" is an async operation completing after the user has navigated away.
// BAD: no guard
Future<void> _loadData() async {
final data = await repository.fetchItems();
setState(() {
_items = data;
});
}
// GOOD: check mounted
Future<void> _loadData() async {
final data = await repository.fetchItems();
if (!mounted) return;
setState(() {
_items = data;
});
}The mounted property is built into every State object. Always check it after any await.
When to Use StatefulWidget vs StatelessWidget vs ConsumerWidget
Choosing the right widget type is a design decision that affects readability, testability, and performance.
StatelessWidget
Use when the widget has no mutable state at all. It receives everything through its constructor and renders it.
class UserAvatar extends StatelessWidget {
final String imageUrl;
final double radius;
const UserAvatar({super.key, required this.imageUrl, this.radius = 24});
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(imageUrl),
);
}
}StatefulWidget
Use when you need local, widget-scoped mutable state: animation controllers, text editing controllers, scroll position tracking, form validation state, or any state that does not belong in a global store.
// Good use of StatefulWidget: local UI state
class ExpandableCard extends StatefulWidget { ... }
class _ExpandableCardState extends State<ExpandableCard> {
bool _isExpanded = false;
// This state is purely UI-local — no need for a provider
}ConsumerWidget (Riverpod)
Use when the widget needs to read or react to state managed by Riverpod providers. ConsumerWidget gives you a ref object to watch providers without requiring a StatefulWidget.
class UserProfileView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return user.when(
data: (data) => Text(data.name),
loading: () => const CircularProgressIndicator(),
error: (e, st) => Text('Error: $e'),
);
}
}Decision Flow
- Does the widget need mutable state? No -> StatelessWidget.
- Is that state managed by Riverpod? Yes -> ConsumerWidget (or ConsumerStatefulWidget if you also need lifecycle methods).
- Is the state local to the widget (animations, controllers, form state)? Yes -> StatefulWidget.
A mistake I see frequently in code reviews: developers reach for StatefulWidget every time they need to "do something". Often the state they manage belongs in a provider, and the widget should be a ConsumerWidget. Other times, the widget is purely presentational and a StatelessWidget would be simpler and more performant.
Debugging Lifecycle Issues
When things go wrong, Flutter usually gives you a helpful error message, but knowing where to look can save hours.
"setState() called after dispose()"
This means an async callback, stream listener, or animation callback tried to update state on a widget that no longer exists. The fix is always the same: guard with mounted or cancel the source.
// Add this to your async methods
if (!mounted) return;Widget Rebuilds Too Often
Use debugPrint inside build to see how often it fires. If a widget rebuilds on every frame, check if an ancestor is calling setState too broadly.
@override
Widget build(BuildContext context) {
debugPrint('${widget.runtimeType} build called');
// ...
}For deeper inspection, use the Flutter DevTools widget inspector. It highlights rebuilds in real time.
initState Runs Again Unexpectedly
If initState fires more than once, it means your widget is being destroyed and recreated. Common causes:
- A parent widget changes the
keyon your widget. - The widget's position in the tree changes (e.g., it is inside a conditional that toggles).
- A
PageVieworListViewevicts off-screen children.
To keep state alive across such events, consider using AutomaticKeepAliveClientMixin or moving state into a provider.
Print the Whole Lifecycle
During development, you can add prints to every lifecycle method to understand exactly what is happening:
@override
void initState() {
super.initState();
debugPrint('[Lifecycle] initState');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('[Lifecycle] didChangeDependencies');
}
@override
void didUpdateWidget(covariant MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('[Lifecycle] didUpdateWidget');
}
@override
void deactivate() {
debugPrint('[Lifecycle] deactivate');
super.deactivate();
}
@override
void dispose() {
debugPrint('[Lifecycle] dispose');
super.dispose();
}Conclusion
Lifecycle discipline is not glamorous, but it is the difference between an app that feels solid and one that occasionally crashes in production with obscure errors. Master initState for setup, didChangeDependencies for context-dependent initialization, build for pure rendering, didUpdateWidget for reacting to parent changes, and dispose for cleanup. Guard every async gap with mounted. Dispose every controller. Cancel every subscription.
If you have screens with complex lifecycle interactions, I can review them for potential leaks and performance issues.
Related Articles
Flutter State Management: Riverpod, Provider, and Bloc Comparison
Compare state management approaches in Flutter. Understand Riverpod, Provider, and Bloc with clear decision criteria for each scenario.
Flutter Performance Optimization: Complete Guide
Improve your Flutter app performance systematically. Learn rebuild optimization, memory management, lazy loading, and profiling techniques.
Dart Best Practices: Writing Clean and Maintainable Code
Learn practical Dart best practices for readability, testability, and long-term maintainability.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch