Skip to content
ExponaBLOG
FLUTTER · DAY 049 / 100

Always dispose controllers: patterns, pitfalls and fixes

Leaked controllers leak memory.

Expona Admin8 min read

Always dispose controllers

I've debugged more memory leaks in Flutter apps than I care to remember, and a shocking number of them trace back to one simple oversight: forgetting to dispose controllers. A leaked TextEditingController or AnimationController doesn't just consume a few kilobytes—it can accumulate across screens, persisting long after the widget tree rebuilds, silently draining your app's memory until users notice stutters or crashes. Today I want to walk you through why this matters, how to do it right, and the tools that can automate it away.

Why controllers need disposal

Controllers in Flutter are stateful objects that hold resources. They maintain internal listeners, animation tickers, and sometimes platform-level handles. When you create a TextEditingController, it doesn't just sit idle—it allocates memory for the text buffer and creates a ChangeNotifier that broadcasts edits to all listeners. An AnimationController spins up a Ticker that syncs with the display's refresh rate. A ScrollController attaches position listeners to the scroll physics engine.

If you never call dispose(), these resources stay alive. The ChangeNotifier keeps firing callbacks to listeners that no longer exist. The Ticker keeps burning CPU cycles. The ScrollController holds references to scroll positions that should have been garbage collected. Over time, especially in apps with heavy navigation or many form screens, this accumulates into a genuine memory leak.

I discovered this the hard way on a ride-hailing app I worked on. Users complained that opening and closing the booking form five or six times made the app laggy. Profiling with DevTools showed dozens of orphaned controllers still holding onto listeners. Once I added proper disposal, the memory profile flattened and the app felt snappy again.

TextEditingController lifecycle

TextEditingController is the most common culprit. Every time you have a form field, search box, or chat input, you're likely creating one. The pattern is straightforward but easy to forget.

dart
class SearchScreen extends StatefulWidget {
  @override
  State<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  late TextEditingController _queryController;

  @override
  void initState() {
    super.initState();
    _queryController = TextEditingController();
    // Optionally, listen for changes
    _queryController.addListener(_onSearchChanged);
  }

  void _onSearchChanged() {
    print('Search query: ${_queryController.text}');
    // Trigger API call, filter, etc.
  }

  @override
  void dispose() {
    _queryController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _queryController,
      decoration: InputDecoration(hintText: 'Search...'),
    );
  }
}

The key steps: create in initState, register any listeners, then always call dispose() in the dispose method. The order matters—dispose the controller before calling super.dispose(). When you dispose, it automatically removes all listeners and flushes the text buffer.

One scenario I see often: developers create a controller but forget it's shared across multiple widgets in the same State. If you pass _queryController to a custom search field widget that also manages its own listeners, you end up with duplicate disposal attempts or missed cleanup. Keep controllers owned by the State that creates them.

AnimationController and tickers

AnimationController is more resource-intensive than TextEditingController because it requires a Ticker—a callback that fires on every frame. If you forget to dispose, the ticker keeps running even after the widget is unmounted, wasting CPU and draining battery on idle screens.

dart
class LoadingScreen extends StatefulWidget {
  @override
  State<LoadingScreen> createState() => _LoadingScreenState();
}

class _LoadingScreenState extends State<LoadingScreen>
    with TickerProviderStateMixin {
  late AnimationController _rotationController;
  late Animation<double> _rotation;

  @override
  void initState() {
    super.initState();
    _rotationController = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this, // Use TickerProviderStateMixin as vsync
    );
    _rotation = Tween<double>(begin: 0, end: 2 * pi).animate(_rotationController);
    _rotationController.repeat(); // Loop forever
  }

  @override
  void dispose() {
    _rotationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return RotationTransition(
      turns: _rotation,
      child: CircularProgressIndicator(),
    );
  }
}

The mixin TickerProviderStateMixin provides the vsync parameter, which synchronizes the ticker to the screen's frame rate. Without it, you can't create an AnimationController at all. The important part: when you call dispose(), it stops the ticker immediately. If you call .repeat() or .forward() on a disposed controller, you'll get an error, so always check your navigation logic.

I worked on a travel app with a loading screen that played a spinning globe animation. The UI code was correct, but QA reported that navigating back and forth would cause jank after a few cycles. The disposed controllers were piling up in memory, and each one was still firing its ticker in the background. Fixing it took one line: ensuring dispose() was called.

ScrollController management

ScrollController lets you programmatically scroll to positions, listen for scroll events, and detect when the user reaches the end of a list. Like other controllers, it needs cleanup.

dart
class InfiniteListScreen extends StatefulWidget {
  @override
  State<InfiniteListScreen> createState() => _InfiniteListScreenState();
}

class _InfiniteListScreenState extends State<InfiniteListScreen> {
  late ScrollController _scrollController;
  List<String> _items = [];

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _scrollController.addListener(_onScroll);
    _loadMore();
  }

  void _onScroll() {
    if (_scrollController.position.pixels ==
        _scrollController.position.maxScrollExtent) {
      _loadMore(); // User reached bottom
    }
  }

  void _loadMore() {
    // Fetch next page from API
    print('Loading more items...');
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: _items.length,
      itemBuilder: (context, index) => ListTile(title: Text(_items[index])),
    );
  }
}

The listener in _onScroll fires frequently as the user scrolls. If you don't dispose the controller, that listener stays registered on the scroll position object, consuming memory and potentially triggering stale API calls if the page rebuilds.

Preventing leaks with lifecycle discipline

The golden rule: for every controller you create in initState, you must call its dispose() method in the dispose override. This applies to TextEditingController, AnimationController, ScrollController, TabController, PageController, and any custom controller that extends ChangeNotifier.

Here's a pattern I use on all my production apps:

dart
class FormScreen extends StatefulWidget {
  @override
  State<FormScreen> createState() => _FormScreenState();
}

class _FormScreenState extends State<FormScreen> {
  late TextEditingController _nameController;
  late TextEditingController _emailController;
  late FocusNode _nameFocus;

  @override
  void initState() {
    super.initState();
    _nameController = TextEditingController();
    _emailController = TextEditingController();
    _nameFocus = FocusNode();
  }

  @override
  void dispose() {
    // Dispose in reverse order of creation (good practice)
    _nameFocus.dispose();
    _emailController.dispose();
    _nameController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _nameController,
          focusNode: _nameFocus,
        ),
        TextField(controller: _emailController),
      ],
    );
  }
}

I dispose in reverse order of creation as a habit—it mirrors constructor patterns and makes debugging easier. More importantly, I never skip super.dispose() at the end.

Using hooks to automate disposal

If you're tired of boilerplate, consider flutter_hooks. It provides hooks that manage the controller lifecycle automatically.

dart
import 'package:flutter_hooks/flutter_hooks.dart';

class SearchScreenWithHooks extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final queryController = useTextEditingController();
    
    useEffect(() {
      void listener() {
        print('Search: ${queryController.text}');
      }
      queryController.addListener(listener);
      
      return () {
        queryController.removeListener(listener);
      };
    }, [queryController]);

    return TextField(
      controller: queryController,
      decoration: InputDecoration(hintText: 'Search...'),
    );
  }
}

Hooks are powerful because they handle disposal automatically. useTextEditingController() disposes the controller when the widget unmounts. useEffect lets you attach side effects like listeners and clean them up with the return closure. No explicit dispose method needed.

On the e-commerce platform I built last year, switching to hooks cut our memory leak surface area dramatically. We still use StatefulWidget for complex screens, but for simple forms and inputs, hooks keep the code cleaner and safer.

Using ProviderController pattern

Another approach is using the provider package with a custom ChangeNotifierProvider:

dart
class SearchProvider extends ChangeNotifier {
  late TextEditingController queryController;

  SearchProvider() {
    queryController = TextEditingController();
    queryController.addListener(notifyListeners);
  }

  @override
  void dispose() {
    queryController.dispose();
    super.dispose();
  }
}

// In your widget:
class SearchScreenWithProvider extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => SearchProvider(),
      child: Consumer<SearchProvider>(
        builder: (context, provider, _) {
          return TextField(controller: provider.queryController);
        },
      ),
    );
  }
}

The ChangeNotifierProvider automatically disposes the SearchProvider when it's removed from the widget tree. This centralizes controller management and makes it harder to accidentally leak.

Common mistakes

  • Forgetting dispose entirely: The most obvious but surprisingly common error. Use a linter rule or code review process to catch it.
  • Disposing in the wrong order: If a controller is referenced by another object being disposed, dispose dependents first.
  • Calling methods after dispose: If your widget tree rebuilds and tries to update a disposed controller's text, you'll crash. Always guard with mounted checks or null safety.
  • Creating controllers in build(): Never do this. Controllers are stateful; they must be created in initState. If you create in build, a new instance spawns on every rebuild, leaking the old ones.
  • Multiple dispose calls: Calling dispose twice on the same controller throws an error. Be careful when sharing controllers or using mixins.
  • Ignoring listener cleanup: If you call addListener and never removeListener, the listener stays alive even after dispose. Use the return closure in useEffect to clean up automatically.

Key takeaways

  • Controllers like TextEditingController, AnimationController, and ScrollController hold resources and must be disposed to prevent memory leaks.
  • Always create controllers in initState and dispose in dispose. Never create them in build().
  • Use TickerProviderStateMixin when creating AnimationController to bind it to the widget's lifecycle.
  • Remove listeners before disposing a controller, or use useEffect to handle cleanup automatically.
  • Consider flutter_hooks for simpler, safer controller management without boilerplate.
  • Use provider or custom ChangeNotifierProvider to centralize controller lifecycle and reduce leak surface.
  • Test with DevTools memory profiler and timeline to catch lingering controllers.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles