Flutter Widget Lifecycle
A StatefulWidget’s State object is created, initialized, built, updated, and finally disposed. Knowing the order lets you set things up and tear them down at exactly the right moment.
Prerequisites: StatelessWidget vs StatefulWidget
Learning objectives
- List the State lifecycle callbacks in the order Flutter invokes them.
- Choose the right callback for setup and teardown work — subscribe in initState, clean up in dispose.
- Explain why context-dependent work belongs in didChangeDependencies, not initState.
- Use the mounted flag and dispose correctly to avoid memory leaks and setState-after-dispose errors.
Simple explanation
Think of a StatefulWidget’s State object as an employee with a shift. When the shift starts they arrive and set up their desk (initState). Once settled, they check the building’s notice board for anything relevant to their job (didChangeDependencies). Then they do the actual work over and over throughout the day (build). If management reshuffles their assignment, they adjust (didUpdateWidget). When something changes that affects their work, they redo the task (setState → build again). At the end of the shift they pack up, return the keys, and switch off the lights (dispose). Skip the pack-up and you leave the lights burning all night — that is a memory leak.
A StatelessWidget has no shift and no desk: it is just a fixed poster on the wall. Only StatefulWidget — through its State — has this lifecycle.
Technical explanation
A StatefulWidget itself is immutable and cheap to recreate. The mutable, long-lived part is its State object, and the framework drives it through a fixed sequence of callbacks:
- createState() — called by the framework once when the widget is inserted into the tree. It returns your
Stateinstance. This is the only method on theStatefulWidgetclass itself; everything below lives onState. - initState() — called exactly once, right after the State is created. Call
super.initState()first. Initialize controllers, subscribe to streams, and set up listeners here. You cannot safely useInheritedWidgetdependencies (e.g.Theme.of(context),MediaQuery.of(context)) yet, because they are not wired up at this point. - didChangeDependencies() — called immediately after
initState, and again every time anInheritedWidgetthis State depends on changes. This is the correct place for context-dependent initialization, such as reading an inherited value or re-subscribing when a provider higher up changes. - build(BuildContext context) — called to describe the UI. It runs many times: after
initState/didChangeDependencies, after everysetState, afterdidUpdateWidget, and whenever an ancestor rebuilds. Keep it pure and fast — no side effects, no subscriptions, no async work. - didUpdateWidget(covariant oldWidget) — called when the parent rebuilds and supplies a new widget configuration of the same type at the same position. Flutter reuses the same State but hands you a new widget. Compare
oldWidgetwithwidgetand, if something like a controller or subscription depends on the changed config, tear down the old one and set up the new one. - setState(fn) — you call this (not the framework) to signal that state has changed. It marks the element dirty and schedules a rebuild, so
buildruns again. Never call it insidebuild, and never afterdispose. - deactivate() — called when the State is removed from the tree, possibly temporarily (e.g. when a subtree is moved via a
GlobalKey). If it is not reinserted in the same frame,disposefollows. - dispose() — called once when the State is permanently removed. Clean up everything you created: dispose controllers, cancel
StreamSubscriptions, remove listeners, close sinks. Callsuper.dispose()last.
The mounted flag is true between initState and dispose. After an async gap, guard with if (!mounted) return; before calling setState, because the widget may already be gone.
Why it matters
Getting the lifecycle right is the difference between an app that runs smoothly and one that leaks memory, throws setState() called after dispose(), or reads context before it is ready. Every animation, stream, timer, text field, and scroll controller you create has to be released at the correct time. The lifecycle tells you exactly when to create and when to release — so resources are held for precisely as long as the widget is on screen and not a millisecond longer.
How it works
For a State that lives, updates, and dies, the framework walks the callbacks like this:
- Parent builds →
createState()→initState()→didChangeDependencies()→build()— the widget is now on screen. - User interaction or new data →
setState()→build()— repeated as often as needed. - An inherited dependency changes →
didChangeDependencies()→build(). - Parent rebuilds with a new config →
didUpdateWidget(oldWidget)→build(). - Widget removed from the tree →
deactivate()→dispose()— resources released, State gone.
Real-world analogy
Renting an apartment maps neatly onto the lifecycle. initState is signing the lease and moving your furniture in — you do it once. didChangeDependencies is checking the building rules, which you re-check whenever management updates them. build is decorating the room, which you might redo many times. didUpdateWidget is the landlord swapping your fridge for a new model — same apartment, new appliance to wire up. dispose is handing back the keys and cancelling the utilities. Forget to cancel the electricity and you keep paying for an empty flat — the memory-leak equivalent.
Important terminology
- State object — the mutable, persistent companion of a StatefulWidget that survives rebuilds and owns the lifecycle callbacks.
- mounted — a boolean on
Statethat istruewhile the State is in the tree (betweeninitStateanddispose); guardsetStateafter async work with it. - setState — the method that marks the State dirty and schedules a rebuild; the only sanctioned way to change UI-affecting state.
- dispose — the final callback where you release every resource the State acquired, preventing memory leaks.
Key definitions
- State object
- The mutable, long-lived companion of a StatefulWidget that persists across rebuilds and hosts the lifecycle callbacks.
- initState
- Called once when the State is created; the place to initialize controllers and subscribe to streams. Call super.initState() first.
- didChangeDependencies
- Runs after initState and again whenever an InheritedWidget dependency changes; the right place for context-dependent setup.
- dispose
- The final callback where you release resources — dispose controllers, cancel subscriptions, remove listeners. Call super.dispose() last.
Must memorize
Must memorize
- Order: createState → initState → didChangeDependencies → build → didUpdateWidget → setState → deactivate → dispose.
- initState runs once (super.initState first); dispose runs once (super.dispose last). Subscribe in one, clean up in the other.
- build can run many times and must stay pure — never do subscriptions or side effects there.
- context-dependent work belongs in didChangeDependencies, not initState; guard async setState with if (!mounted) return.
Real-world example
A live chat screen with a message stream
A chat screen subscribes to a WebSocket message stream in initState and calls setState as messages arrive. When the user navigates away, dispose cancels the StreamSubscription. Without that cancellation the socket stays open and the closure keeps calling setState on a dead State, throwing errors and leaking memory.
A loading spinner driven by an AnimationController
A spinner creates an AnimationController (with a TickerProvider) in initState and repeats it. In dispose the controller is disposed so its ticker stops. Skipping dispose keeps the ticker alive, wasting frames and battery even after the screen is closed.
Code example
class PulsingDot extends StatefulWidget {
const PulsingDot({super.key});
@override
State<PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<PulsingDot>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState(); // always first
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat(reverse: true);
}
@override
Widget build(BuildContext context) {
// build stays pure — it only describes UI.
return FadeTransition(
opacity: _controller,
child: const CircleAvatar(radius: 12),
);
}
@override
void dispose() {
_controller.dispose(); // release the ticker
super.dispose(); // always last
}
}An AnimationController created in initState and released in dispose.
class MessageList extends StatefulWidget {
const MessageList({super.key, required this.stream});
final Stream<String> stream;
@override
State<MessageList> createState() => _MessageListState();
}
class _MessageListState extends State<MessageList> {
final List<String> _messages = [];
StreamSubscription<String>? _sub;
@override
void initState() {
super.initState();
_subscribe(widget.stream);
}
@override
void didUpdateWidget(covariant MessageList oldWidget) {
super.didUpdateWidget(oldWidget);
// Parent gave us a new stream — swap the subscription.
if (oldWidget.stream != widget.stream) {
_sub?.cancel();
_subscribe(widget.stream);
}
}
void _subscribe(Stream<String> stream) {
_sub = stream.listen((msg) {
if (!mounted) return; // widget may be gone after an async event
setState(() => _messages.add(msg));
});
}
@override
Widget build(BuildContext context) {
return ListView(
children: [for (final m in _messages) Text(m)],
);
}
@override
void dispose() {
_sub?.cancel(); // cancel the subscription to avoid a leak
super.dispose();
}
}Subscribing to a stream in initState, guarding setState with mounted, and cancelling in dispose.
class ThemedBanner extends StatefulWidget {
const ThemedBanner({super.key});
@override
State<ThemedBanner> createState() => _ThemedBannerState();
}
class _ThemedBannerState extends State<ThemedBanner> {
late Color _accent;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Safe here: inherited widgets are wired up.
// Also re-runs automatically when the Theme changes.
_accent = Theme.of(context).colorScheme.primary;
}
@override
Widget build(BuildContext context) {
return Container(color: _accent, height: 48);
}
}Reading an InheritedWidget dependency in didChangeDependencies, not initState.
Common mistakes
Mistake
Forgetting to release resources in dispose — leaving AnimationControllers, StreamSubscriptions, or listeners alive.
Do this instead
Every controller/subscription/listener created in initState must be disposed or cancelled in dispose, or it leaks memory and keeps firing callbacks.
Mistake
Calling setState after an await when the widget may be gone, or using Theme.of(context) in initState.
Do this instead
Guard async updates with if (!mounted) return; before setState, and move context-dependent reads to didChangeDependencies or build.
Interview questions
Quiz
Flashcards
Show answer · Space