setState & the Lifecycle
What setState actually does
Section titled “What setState actually does”setState does not “redraw the screen.” It does one small thing: it runs your callback (which mutates fields), then marks this widget’s element as dirty. On the next frame, Flutter rebuilds every dirty element by calling build again.
class Counter extends StatefulWidget { const Counter({super.key}); @override State<Counter> createState() => _CounterState();}
class _CounterState extends State<Counter> { int _count = 0;
void _increment() { setState(() { _count++; // mutate state INSIDE the callback }); // then Flutter marks this element dirty }
@override Widget build(BuildContext context) { return TextButton( onPressed: _increment, child: Text('count: $_count'), ); }}Two rules fall out of this: mutate state inside the setState callback (so the change and the dirty-marking happen together), and know that setState schedules a rebuild for the next frame — it is not synchronous.
The State lifecycle, proven in order
Section titled “The State lifecycle, proven in order”A State object has a well-defined lifecycle. Knowing which hook does what prevents a whole class of bugs — and the order isn’t just documentation, it’s testable.
flowchart TB create["createState()"] --> init["initState() one-time setup"] init --> deps["didChangeDependencies() after inherited deps ready"] deps --> build["build() called on every rebuild"] build --> update["didUpdateWidget() parent rebuilt with new config"] update --> build build --> deactivate["deactivate() removed from the tree"] deactivate --> dispose["dispose() not reinserted — cleanup"]
import 'package:flutter/material.dart';
class LifecycleLogger extends StatefulWidget { const LifecycleLogger({super.key, required this.value, required this.log}); final int value; final List<String> log;
@override State<LifecycleLogger> createState() => _LifecycleLoggerState();}
class _LifecycleLoggerState extends State<LifecycleLogger> { @override void initState() { super.initState(); widget.log.add('initState'); }
@override void didChangeDependencies() { super.didChangeDependencies(); widget.log.add('didChangeDependencies'); }
@override void didUpdateWidget(covariant LifecycleLogger oldWidget) { super.didUpdateWidget(oldWidget); widget.log.add('didUpdateWidget'); }
@override void deactivate() { widget.log.add('deactivate'); super.deactivate(); }
@override void dispose() { widget.log.add('dispose'); super.dispose(); }
@override Widget build(BuildContext context) { widget.log.add('build'); return Text('${widget.value}'); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/lifecycle_logger.dart';
void main() { testWidgets( 'full State lifecycle order: initState, didChangeDependencies, build, didUpdateWidget, deactivate, dispose', (tester) async { final log = <String>[];
await tester.pumpWidget(MaterialApp(home: LifecycleLogger(value: 1, log: log))); expect(log, ['initState', 'didChangeDependencies', 'build']);
// Parent rebuilds with a new config for the same widget/State. await tester.pumpWidget(MaterialApp(home: LifecycleLogger(value: 2, log: log))); expect(log, ['initState', 'didChangeDependencies', 'build', 'didUpdateWidget', 'build']);
// Widget removed from the tree entirely. await tester.pumpWidget(const MaterialApp(home: SizedBox())); expect(log, [ 'initState', 'didChangeDependencies', 'build', 'didUpdateWidget', 'build', 'deactivate', 'dispose', ]); }, );}✓ full State lifecycle order: initState, didChangeDependencies, build, didUpdateWidget, deactivate, dispose
00:00 +1: All tests passed!initState— one-time setup: create controllers, subscribe to streams. Runs once. You cannot reliably usecontextfor inherited widgets here (proven below).didChangeDependencies— runs right afterinitState, and again whenever an inherited dependency thisStatereads actually changes. The safe place to readInheritedWidget/Provider/Theme.ofdata that setup depends on.build— called on every rebuild (proven above: twice for one config change — once for the initial mount, once for the update). Must be pure and fast: no side effects, no network calls, no subscriptions.didUpdateWidget— the parent rebuilt and gave thisStatea new widget config; react to changed props (e.g. re-subscribe if anidchanged). Runs before thebuildit triggers, confirmed by the log order above.deactivate— the element was removed from the tree. It might still be reinserted elsewhere before the frame ends (e.g. moved under aGlobalKey) — Flutter doesn’t yet know it’s permanent.dispose— called once Flutter confirms the element was not reinserted: clean up controllers, cancel subscriptions. Forgetting this is the classic Flutter memory leak.
mounted: guarding setState after an await
Section titled “mounted: guarding setState after an await”initState kicking off an async load is common — but the State can be disposed (user navigates away, list item scrolled out and rebuilt) while that Future is still pending. When it resolves and calls setState, there is no widget left to rebuild.
import 'package:flutter/material.dart';
/// Toggle: whether the widget guards its post-await setState with `mounted`.class DelayedLabel extends StatefulWidget { const DelayedLabel({super.key, required this.guarded}); final bool guarded;
@override State<DelayedLabel> createState() => _DelayedLabelState();}
class _DelayedLabelState extends State<DelayedLabel> { String _text = 'loading';
@override void initState() { super.initState(); _load(); }
Future<void> _load() async { await Future<void>.delayed(const Duration(milliseconds: 20)); // ❌ Without the guard: this widget may already be disposed (e.g. the // user navigated away before the delay finished) — setState throws. if (widget.guarded && !mounted) return; setState(() => _text = 'loaded'); }
@override Widget build(BuildContext context) => Text(_text);}Unguarded (guarded: false), unmounted mid-flight, run in this probe — the real, unedited flutter test failure:
setState() called after dispose(): _DelayedLabelState#27577(lifecycle state: defunct, not mounted)This error happens if you call setState() on a State object for a widget that no longer appears inthe widget tree (e.g., whose parent widget no longer includes the widget in its build). This errorcan occur when code calls setState() from a timer, from an animation callback, or after anasynchronous operation (such as an awaited network request or other Future) completes after thewidget has been removed from the tree.The preferred solution is to cancel the timer or stop listening to the animation in the dispose()callback. Another solution is to check the "mounted" property of this object before callingsetState() to ensure the object is still in the tree.This error might indicate a memory leak if setState() is being called because another object isretaining a reference to this State object after it has been removed from the tree. To avoid memoryleaks, consider breaking the reference to this object during dispose().The if (widget.guarded && !mounted) return; line is the fix — mounted (a real getter on State, true until dispose() runs) turns the crash into a harmless no-op:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/mounted_after_await.dart';
void main() { testWidgets('mounted-guarded setState after dispose is a silent no-op (no crash)', (tester) async { await tester.pumpWidget(const MaterialApp(home: DelayedLabel(guarded: true))); // Unmount before the 20ms delay resolves. await tester.pumpWidget(const MaterialApp(home: SizedBox())); // Let the pending Future run past the mounted check. await tester.pump(const Duration(milliseconds: 30)); expect(tester.takeException(), isNull); });}✓ mounted-guarded setState after dispose is a silent no-op (no crash)
00:00 +1: All tests passed!setState called while something is already building
Section titled “setState called while something is already building”The rule “never call setState inside build” has a precise version: Flutter allows marking a widget dirty during a build pass only if that widget is a descendant of whatever is currently building — because parents build before children, so a dirty descendant is guaranteed to still be visited this pass. Reach sideways (a sibling) or upward, and Flutter can’t make that guarantee, and refuses:
import 'package:flutter/material.dart';
class SiblingA extends StatefulWidget { const SiblingA({super.key}); @override State<SiblingA> createState() => SiblingAState();}
class SiblingAState extends State<SiblingA> { int count = 0; @override Widget build(BuildContext context) => Text('$count');}
/// Reaches sideways into a SIBLING's State and calls setState on it while/// THIS widget is still building — not a descendant relationship, so the/// framework cannot guarantee the sibling will still be visited this pass.class SiblingB extends StatelessWidget { const SiblingB({super.key, required this.aKey}); final GlobalKey<SiblingAState> aKey;
@override Widget build(BuildContext context) { // ❌ setState() on a sibling's State, called from this widget's build(). aKey.currentState?.setState(() { aKey.currentState!.count++; }); return const SizedBox(); }}
class BadCrossWidgetSetState extends StatelessWidget { BadCrossWidgetSetState({super.key}); final aKey = GlobalKey<SiblingAState>();
@override Widget build(BuildContext context) { return Column(children: [SiblingA(key: aKey), SiblingB(aKey: aKey)]); }}flutter analyze already flags the smell before you even run it — setState is @protected, meaning only SiblingAState itself is supposed to call it:
warning • The member 'setState' can only be used within instance members of subclasses of 'State' • invalid_use_of_protected_memberRunning it anyway (MaterialApp(home: Scaffold(body: BadCrossWidgetSetState()))), the real, unedited flutter test output:
The following assertion was thrown building SiblingB(dirty):setState() or markNeedsBuild() called during build.This SiblingA widget cannot be marked as needing to build because the framework is already in theprocess of building widgets. A widget can be marked as needing to be built during the build phaseonly if one of its ancestors is currently building. This exception is allowed because the frameworkbuilds parent widgets before children, which means a dirty descendant will always be built.Otherwise, the framework might not visit this widget during this build phase.The widget on which setState() or markNeedsBuild() was called was: SiblingA-[LabeledGlobalKey<SiblingAState>#e3b3b]The widget which was currently being built when the offending call was made was: SiblingBThis is also why the very first example’s own setState inside its onPressed callback (not inside build) is fine: an event handler runs between frames, when nothing is building — there is no _debugCurrentBuildTarget to conflict with.
initState can’t reliably read inherited widgets
Section titled “initState can’t reliably read inherited widgets”context.dependOnInheritedWidgetOfExactType<T>() (what Theme.of, MediaQuery.of, Provider.of all call under the hood) registers this State’s element as a dependent, so it rebuilds automatically when that inherited value changes. During initState, the element isn’t fully wired into that dependency graph yet:
import 'package:flutter/material.dart';
/// Calls dependOnInheritedWidgetOfExactType from initState() — too early.class BadInitStateInherited extends StatefulWidget { const BadInitStateInherited({super.key}); @override State<BadInitStateInherited> createState() => _BadInitStateInheritedState();}
class _BadInitStateInheritedState extends State<BadInitStateInherited> { @override void initState() { super.initState(); // ❌ context is not fully hooked into the inherited-widget dependency // tree yet inside initState(). context.dependOnInheritedWidgetOfExactType<MediaQuery>(); }
@override Widget build(BuildContext context) => const SizedBox();}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/initstate_inherited.dart';
void main() { testWidgets('dependOnInheritedWidgetOfExactType in initState throws', (tester) async { await tester.pumpWidget(const MaterialApp(home: BadInitStateInherited())); final exception = tester.takeException(); expect(exception, isA<FlutterError>()); expect( exception.toString(), contains('dependOnInheritedWidgetOfExactType<MediaQuery>() or dependOnInheritedElement() was called before'), ); });}✓ dependOnInheritedWidgetOfExactType in initState throws
00:00 +1: All tests passed!The real assertion text, captured in full:
dependOnInheritedWidgetOfExactType<MediaQuery>() or dependOnInheritedElement() was called before_BadInitStateInheritedState.initState() completed.When an inherited widget changes, for example if the value of Theme.of() changes, its dependentwidgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructoror an initState() method, then the rebuilt dependent widget will not reflect the changes in theinherited widget.Typically references to inherited widgets should occur in widget build() methods. Alternatively,initialization based on inherited widgets can be placed in the didChangeDependencies method, whichis called after initState and whenever the dependencies change thereafter.The fix is exactly what the message says: move the read into build(), or into didChangeDependencies() if it’s one-time setup that genuinely needs the inherited value (that hook runs once right after initState, by which point the dependency wiring is ready — proven in the lifecycle-order test above, where didChangeDependencies logged immediately after initState).
late fields and initState
Section titled “late fields and initState”A late final field is a promise: “I will assign this before anything reads it.” initState is the idiomatic place to keep that promise for per-instance resources (TextEditingController, AnimationController, FocusNode) that need widget or this to construct — a plain field initializer runs before the constructor body and can’t reach widget yet.
import 'package:flutter/material.dart';
/// A `late` field is a promise: "I will assign this before anything reads/// it." initState() is the idiomatic place to keep that promise for/// per-instance resources (controllers, focus nodes) that need `this` or/// `widget` to construct — a plain field initializer runs too early for/// that (see the lesson prose).class ProfileField extends StatefulWidget { const ProfileField({super.key, required this.userId, this.skipInit = false}); final String userId; final bool skipInit; // forces the broken path, for the proof below
@override State<ProfileField> createState() => _ProfileFieldState();}
class _ProfileFieldState extends State<ProfileField> { late final TextEditingController _controller;
@override void initState() { super.initState(); if (!widget.skipInit) { _controller = TextEditingController(text: 'user-${widget.userId}'); } // ❌ if skipInit is true, `_controller` is never assigned here. }
@override void dispose() { if (!widget.skipInit) { _controller.dispose(); } super.dispose(); }
@override Widget build(BuildContext context) => TextField(controller: _controller);}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/late_initstate.dart';
void main() { testWidgets('late field assigned in initState is ready by first build', (tester) async { await tester.pumpWidget( const MaterialApp(home: Scaffold(body: ProfileField(userId: '42'))), ); final controller = tester.widget<TextField>(find.byType(TextField)).controller!; expect(controller.text, 'user-42'); });
testWidgets('a late field never assigned throws LateInitializationError on first read', (tester) async { await tester.pumpWidget( const MaterialApp(home: Scaffold(body: ProfileField(userId: '42', skipInit: true))), ); final exception = tester.takeException(); expect(exception, isA<Error>()); expect(exception.toString(), contains('LateInitializationError')); expect(exception.toString(), contains('has not been initialized')); });}✓ late field assigned in initState is ready by first build✓ a late field never assigned throws LateInitializationError on first read
00:00 +2: All tests passed!The real message (the field name gets a run-specific hash suffix): LateInitializationError: Field '_controller@23181088' has not been initialized. — a LateError, thrown the first time build() reads _controller, not at the initState call site itself (the skipped branch doesn’t throw; the later read does). This is exactly why the “obvious” fix of wrapping construction in an if without an else is a trap: it compiles clean (the analyzer trusts your late promise), and only fails the first time that branch is actually hit at runtime.
The scope of a rebuild
Section titled “The scope of a rebuild”setState marks this State’s element dirty, so build re-runs and produces a new subtree — but Flutter is smart about it. It diffs the new widget subtree against the old element tree and only touches what changed. Still, the whole build method runs, so a setState high in the tree that rebuilds a huge subtree is a real performance concern.
The practical lesson: push setState as low in the tree as possible. Isolate the changing bit into its own small StatefulWidget so only that tiny subtree rebuilds, not the whole page. (The performance module goes deeper on this.)
Mental Model
Section titled “Mental Model”A State object is a little state machine, and every hook answers “what changed to get me here?” initState: I was just created — set up resources that live as long as I do. didChangeDependencies: an InheritedWidget I depend on just became readable (or changed) — react to it, never in initState where that wiring isn’t finished. build: someone asked what I look like right now — answer honestly and quickly, no side effects. didUpdateWidget: my parent kept me alive but gave me new configuration — reconcile, don’t recreate. deactivate/dispose: I’m leaving — deactivate is “maybe temporarily,” dispose is “for real, clean up.” mounted is just “has dispose run yet?” — the one question every async callback must ask before touching setState.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Guard async setState with mounted | No crash if the widget is gone by the time a Future resolves | One more line at every async callback site — easy to forget |
Read inherited data in build() | Always correctly wired, always current | Runs on every rebuild — keep the read itself cheap |
Read inherited data in didChangeDependencies() instead | Runs once per actual dependency change, not every build | Only right for one-time setup driven by that value, not for values used directly in the UI |
late final for widget-dependent fields | Compiles clean, no nullable-then-bang everywhere | A forgotten assignment path becomes a runtime LateInitializationError, not a compile error |
Isolate setState in a small child StatefulWidget | Smaller rebuild scope | One more widget/file to maintain |
Common Misconceptions
Section titled “Common Misconceptions”- “setState() redraws the screen immediately.” It mutates fields and marks the element dirty — the actual rebuild happens on the next frame, not synchronously inside the call.
- “Any setState() call is safe as long as it compiles.” Proven above: an unguarded
setStateafterdispose()compiles fine and crashes at runtime; calling another widget’ssetStatefrom your ownbuild()compiles fine (with an analyzer warning, sincesetStateis@protected) and crashes at runtime too. - “initState is a safe place to read Theme.of/MediaQuery.of/Provider.of.” Proven above: the real error is explicit that this must move to
build()ordidChangeDependencies(). - “A
latefield is basically the same asT?with!.” Alatefield that’s never assigned throws a specificLateInitializationErrorthe first time it’s read, not when it’s declared or skipped — proven above, and the failure point can be far from the actual bug (a conditional ininitState). - “deactivate() and dispose() are the same hook with two names.”
deactivatefires when an element leaves the tree and might still come back (e.g. moved under aGlobalKey) this same frame;disposeonly fires once Flutter has confirmed it’s gone for good — proven in order in the lifecycle test above.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated StatefulWidgets tend to get the shape of the lifecycle right but skip the guard that only matters under a race condition — which is exactly the kind of bug that passes a quick manual test and ships.
// @expect-error missing `mounted` guard — real "setState() called after dispose()" crash, proven aboveclass UserBio extends StatefulWidget { const UserBio({super.key, required this.userId}); final String userId; @override State<UserBio> createState() => _UserBioState();}
class _UserBioState extends State<UserBio> { String _bio = 'Loading...';
@override void initState() { super.initState(); fetchBio(widget.userId).then((bio) { setState(() => _bio = bio); // no mounted check }); }
@override Widget build(BuildContext context) => Text(_bio);}Before trusting a generated StatefulWidget, check for:
- Every
setStatereachable from an async callback (.then,awaitcontinuation,Timer,Streamlistener) guarded byif (!mounted) return;— or the callback itself cancelled indispose(). context.dependOnInheritedWidgetOfExactType/Theme.of/MediaQuery.of/Provider.ofcalls living inbuild()ordidChangeDependencies(), neverinitState().- A
latefield’s every assignment path actually runs before the field is first read — anifwithout anelsearound alateassignment ininitStateis a real, easy-to-miss trap (proven above). setStatenever called on another widget’sStateobject from inside a different widget’sbuild()— a real@protectedviolation the analyzer will flag, on top of the runtime assertion.
🐛 Spot the bug
class SearchResults extends StatefulWidget {const SearchResults({super.key, required this.query});final String query;@overrideState<SearchResults> createState() => _SearchResultsState();}
class _SearchResultsState extends State<SearchResults> {late List<String> _results;
@overridevoid initState() { super.initState(); if (widget.query.isNotEmpty) { _results = _search(widget.query); }}
@overrideWidget build(BuildContext context) { return Text('${_results.length} results');}}Show the answer
_results is late but only assigned when widget.query.isNotEmpty — construct this widget with an empty query and build() throws LateInitializationError: Field '_results' has not been initialized. the moment it reads _results.length, exactly the trap proven earlier in this lesson. The fix: give _results a real default (late List<String> _results = []; or assign _results = const []; in the else branch) so every path through initState leaves the promise kept.