Back to all tutorials
Tested: Flutter 3.24.3 • Dart 3.5.3
T02.05 •
Verified September 2026
Lifecycle dispose AnimationController Timer Memory Leaks
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 Tested Flutter Code
main.dart (Flutter 3.x+ ready) lib/main.dart Copy & Paste into a new Flutter project
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()
Remedy: Periodic timers remain active indefinitely in the Dart VM until explicitly canceled with timer.cancel().
Pitfall #2: Calling super.dispose() before cleaning up local controllers
Remedy: Always perform your own teardown first, then call super.dispose() as the final line of dispose().
Pitfall #3: Disposing a controller that is passed in as a constructor parameter
Remedy: Only dispose controllers created inside initState(). If a controller was passed in by the parent widget, the parent owns its lifecycle.