Flutter

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

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: 3, vsync: this);
  _scrollController = ScrollController();
  _animationController = AnimationController(
    duration: const Duration(milliseconds: 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();
  // Safe to use context here
  final theme = Theme.of(context);
  _backgroundColor = theme.colorScheme.surface;

  // 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) {
    // 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() {
  // 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;

  // 1. initState — one-time setup
  @override
  void initState() {
    super.initState();
    _animController = AnimationController(
      duration: const Duration(milliseconds: 400),
      vsync: this,
    );
    _scrollController = ScrollController();
    _subscribeToChannel(widget.channelId);
  }

  // 2. didChangeDependencies — context-dependent init
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Access inherited widgets safely here
    final brightness = Theme.of(context).brightness;
    _animController.duration = brightness == Brightness.dark
        ? const Duration(milliseconds: 600)
        : const Duration(milliseconds: 400);
  }

  // 3. build — pure UI description
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('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()),
                );
              },
            ),
    );
  }

  // 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);
    }
  }

  // 5. deactivate — tree removal (temporary)
  @override
  void deactivate() {
    super.deactivate();
  }

  // 6. dispose — permanent cleanup
  @override
  void dispose() {
    _mounted = false;
    _dataSubscription?.cancel();
    _animController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  // Helper
  void _subscribeToChannel(String channelId) {
    _dataSubscription = DataService.subscribe(channelId).listen(
      (event) {
        if (!_mounted) return;
        setState(() {
          _events.add(event);
          _isLoading = false;
        });
      },
      onError: (error) {
        debugPrint('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
// BAD: subscription never cancelled
@override
void initState() {
  super.initState();
  FirebaseFirestore.instance
      .collection('messages')
      .snapshots()
      .listen((snapshot) {
    setState(() {
      _messages = snapshot.docs;
    });
  });
}

// GOOD: store the subscription and cancel it
late final StreamSubscription _messagesSub;

@override
void initState() {
  super.initState();
  _messagesSub = FirebaseFirestore.instance
      .collection('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
// BAD: controller leaks
late final AnimationController _ctrl;

@override
void initState() {
  super.initState();
  _ctrl = AnimationController(vsync: this, duration: Durations.medium1);
  _ctrl.repeat();
}

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

// 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 = 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
// Good use of StatefulWidget: local UI state
class ExpandableCard extends StatefulWidget { ... }
class _ExpandableCardState extends State<ExpandableCard> {
  bool _isExpanded = false;
  // 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('Error: $e'),
    );
  }
}

Decision Flow

  1. Does the widget need mutable state? No -> StatelessWidget.
  2. Is that state managed by Riverpod? Yes -> ConsumerWidget (or ConsumerStatefulWidget if you also need lifecycle methods).
  3. 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
// 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('${widget.runtimeType} build called');
  // ...
}

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('[Lifecycle] initState');
}

@override
void didChangeDependencies() {
  super.didChangeDependencies();
  debugPrint('[Lifecycle] didChangeDependencies');
}

@override
void didUpdateWidget(covariant MyWidget oldWidget) {
  super.didUpdateWidget(oldWidget);
  debugPrint('[Lifecycle] didUpdateWidget');
}

@override
void deactivate() {
  debugPrint('[Lifecycle] deactivate');
  super.deactivate();
}

@override
void dispose() {
  debugPrint('[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