Skip to main content
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.

Official Flutter References & Standards