Flutter Widget Lifecycle: From initState to dispose

12 min readFebruary 9, 2026Updated: Mar 9, 2026
Flutter lifecycleinitState disposeStatefulWidget lifecycleFlutter memory leakFlutter setState after disposeFlutter widget rebuildFlutter stateful widgetFlutter best practices

# 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.

dart
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.

dart
@override
void initState() {
  super.initState();
  _tabController = TabController(length: class="code-number">3, vsync: this);
  _scrollController = ScrollController();
  _animationController = AnimationController(
    duration: const Duration(milliseconds: class="code-number">300),
    vsync: this,
  );
  _loadUserProfile();
}

Rules:

  • Always call `super.initState()` first.
  • You cannot use `context` to look up inherited widgets here because the widget is not yet fully mounted in the tree. Use `didChangeDependencies` for that.
  • Keep it synchronous. If you need async work, call an async method from here but do not make `initState` itself 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.

    dart
    @override
    void didChangeDependencies() {
      super.didChangeDependencies();
      class=class="code-string">"code-comment">// Safe to use context here
      final theme = Theme.of(context);
      _backgroundColor = theme.colorScheme.surface;
    
      class=class="code-string">"code-comment">// 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.

    dart
    @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 `setState` inside `build`.
  • 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.

    dart
    @override
    void didUpdateWidget(covariant ProfileScreen oldWidget) {
      super.didUpdateWidget(oldWidget);
      if (oldWidget.userId != widget.userId) {
        class=class="code-string">"code-comment">// 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.

    dart
    @override
    void deactivate() {
      class=class="code-string">"code-comment">// 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.

    dart
    @override
    void dispose() {
      _tabController.dispose();
      _scrollController.dispose();
      _animationController.dispose();
      _subscription?.cancel();
      _focusNode.dispose();
      super.dispose();
    }

    Rules:

  • Always call `super.dispose()` last.
  • Never call `setState` after `dispose` has run. This is the source of the dreaded "setState() called after dispose()" error.
  • Complete Example: All Lifecycle Methods Together

    dart
    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;
    
      class=class="code-string">"code-comment">// class="code-number">1. initState — one-time setup
      @override
      void initState() {
        super.initState();
        _animController = AnimationController(
          duration: const Duration(milliseconds: class="code-number">400),
          vsync: this,
        );
        _scrollController = ScrollController();
        _subscribeToChannel(widget.channelId);
      }
    
      class=class="code-string">"code-comment">// class="code-number">2. didChangeDependencies — context-dependent init
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        class=class="code-string">"code-comment">// Access inherited widgets safely here
        final brightness = Theme.of(context).brightness;
        _animController.duration = brightness == Brightness.dark
            ? const Duration(milliseconds: class="code-number">600)
            : const Duration(milliseconds: class="code-number">400);
      }
    
      class=class="code-string">"code-comment">// class="code-number">3. build — pure UI description
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text(class="code-string">'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()),
                    );
                  },
                ),
        );
      }
    
      class=class="code-string">"code-comment">// class="code-number">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);
        }
      }
    
      class=class="code-string">"code-comment">// class="code-number">5. deactivate — tree removal (temporary)
      @override
      void deactivate() {
        super.deactivate();
      }
    
      class=class="code-string">"code-comment">// class="code-number">6. dispose — permanent cleanup
      @override
      void dispose() {
        _mounted = false;
        _dataSubscription?.cancel();
        _animController.dispose();
        _scrollController.dispose();
        super.dispose();
      }
    
      class=class="code-string">"code-comment">// Helper
      void _subscribeToChannel(String channelId) {
        _dataSubscription = DataService.subscribe(channelId).listen(
          (event) {
            if (!_mounted) return;
            setState(() {
              _events.add(event);
              _isLoading = false;
            });
          },
          onError: (error) {
            debugPrint(class="code-string">'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

    dart
    class=class="code-string">"code-comment">// BAD: subscription never cancelled
    @override
    void initState() {
      super.initState();
      FirebaseFirestore.instance
          .collection(class="code-string">'messages')
          .snapshots()
          .listen((snapshot) {
        setState(() {
          _messages = snapshot.docs;
        });
      });
    }
    
    class=class="code-string">"code-comment">// GOOD: store the subscription and cancel it
    late final StreamSubscription _messagesSub;
    
    @override
    void initState() {
      super.initState();
      _messagesSub = FirebaseFirestore.instance
          .collection(class="code-string">'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.

    dart
    class=class="code-string">"code-comment">// BAD: controller leaks
    late final AnimationController _ctrl;
    
    @override
    void initState() {
      super.initState();
      _ctrl = AnimationController(vsync: this, duration: Durations.medium1);
      _ctrl.repeat();
    }
    
    class=class="code-string">"code-comment">// 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.

    dart
    @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.

    dart
    class=class="code-string">"code-comment">// BAD: no guard
    Future<void> _loadData() async {
      final data = await repository.fetchItems();
      setState(() {
        _items = data;
      });
    }
    
    class=class="code-string">"code-comment">// 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.

    dart
    class UserAvatar extends StatelessWidget {
      final String imageUrl;
      final double radius;
    
      const UserAvatar({super.key, required this.imageUrl, this.radius = class="code-number">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.

    dart
    class=class="code-string">"code-comment">// Good use of StatefulWidget: local UI state
    class ExpandableCard extends StatefulWidget { ... }
    class _ExpandableCardState extends State<ExpandableCard> {
      bool _isExpanded = false;
      class=class="code-string">"code-comment">// 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.

    dart
    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(class="code-string">'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.

    dart
    class=class="code-string">"code-comment">// 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.

    dart
    @override
    Widget build(BuildContext context) {
      debugPrint(class="code-string">'${widget.runtimeType} build called');
      class=class="code-string">"code-comment">// ...
    }

    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 `key` on your widget.
  • The widget's position in the tree changes (e.g., it is inside a conditional that toggles).
  • A `PageView` or `ListView` evicts 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:

    dart
    @override
    void initState() {
      super.initState();
      debugPrint(class="code-string">'[Lifecycle] initState');
    }
    
    @override
    void didChangeDependencies() {
      super.didChangeDependencies();
      debugPrint(class="code-string">'[Lifecycle] didChangeDependencies');
    }
    
    @override
    void didUpdateWidget(covariant MyWidget oldWidget) {
      super.didUpdateWidget(oldWidget);
      debugPrint(class="code-string">'[Lifecycle] didUpdateWidget');
    }
    
    @override
    void deactivate() {
      debugPrint(class="code-string">'[Lifecycle] deactivate');
      super.deactivate();
    }
    
    @override
    void dispose() {
      debugPrint(class="code-string">'[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

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch