Clean Up Controllers, Listeners, Timers and Async Callbacks
Prevent memory leaks and "setState() called after dispose()" crashes by mastering resource disposal order and lifecycle cleanup.
1. The Core Challenge & Problem
Failing to cancel active Periodic Timers, StreamSubscriptions, TextEditingControllers, or AnimationControllers in dispose() leaves active memory references retained in the root event loop. When periodic timers or listeners fire after the user has navigated away from a screen, they attempt to notify listeners or call setState() on a detached State object, resulting in the dreaded Flutter assertion crash: "setState() called after dispose(): <State> (lifecycle state: defunct, not mounted)".
2. Architectural Principles & Resolution
Resource cleanup in Flutter follows a strict contract: 1. Cancel Active Tickers & Timers First: Tickers and timers generate continuous event loop ticks. Call _timer?.cancel(); and _controller.stop(); before disposal. 2. Remove Attached Listeners: If you registered custom listeners via _controller.addListener(_myListener);, always remove them with _controller.removeListener(_myListener); prior to calling _controller.dispose();. 3. Always Call super.dispose() Last: Ensure child and framework resources are disposed cleanly before releasing the superclass State binding.
3. Complete Flutter Code
main.dart (Flutter 3.x+ ready)import 'dart:async';
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: LifecycleCleanupDemo()));
class LifecycleCleanupDemo extends StatefulWidget {
const LifecycleCleanupDemo({super.key});
@override
State<LifecycleCleanupDemo> createState() => _LifecycleCleanupDemoState();
}
class _LifecycleCleanupDemoState extends State<LifecycleCleanupDemo> with SingleTickerProviderStateMixin {
late final TextEditingController _textController;
late final AnimationController _animController;
Timer? _pollingTimer;
int _ticks = 0;
@override
void initState() {
super.initState();
_textController = TextEditingController(text: 'Initial Query');
_textController.addListener(_onTextChanged);
_animController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
// Periodic timer polling background data every second
_pollingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
_ticks++;
});
});
}
void _onTextChanged() {
// Custom listener action
}
@override
void dispose() {
// 1. Cancel timers immediately to prevent subsequent event loop triggers
_pollingTimer?.cancel();
_pollingTimer = null;
// 2. Remove listeners before disposing controller
_textController.removeListener(_onTextChanged);
_textController.dispose();
// 3. Stop and dispose AnimationController
_animController.dispose();
// 4. Always call super.dispose() as the final statement
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('T02.05: Lifecycle Cleanup')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(controller: _textController),
const SizedBox(height: 16),
Text('Active Polling Ticks: $_ticks', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
AnimatedBuilder(
animation: _animController,
builder: (context, child) => CircularProgressIndicator(value: _animController.value),
),
],
),
),
);
}
} 4. Expected Visual & Behavioral Result
Navigating away or popping the route immediately terminates the polling timer and disposes the controllers. Zero memory leaks occur, and no "setState() called after dispose()" exceptions appear in debug console.
5. Common Pitfalls & Traps
Pitfall #1: Forgetting to cancel periodic Timers before calling super.dispose()
Pitfall #2: Calling super.dispose() before cleaning up local controllers
Pitfall #3: Disposing a controller that is passed in as a constructor parameter