Stateless vs Stateful Widget & Lifecycle
The Question Every Flutter Interview Starts With
Open interactive version (quiz + challenge)Real-world analogy
A StatelessWidget is like a printed photograph — it shows exactly what it was told to show, and never changes. A StatefulWidget is like a digital photo frame — it can update its display when new data arrives. The lifecycle methods are the frame's built-in events: power on (initState), display update (build), photo changed (didUpdateWidget), unplugged (dispose).
What is it?
StatelessWidget is an immutable widget whose UI depends solely on its constructor parameters. StatefulWidget pairs an immutable widget with a mutable State object that persists across rebuilds. The lifecycle — initState, didChangeDependencies, build, didUpdateWidget, deactivate, dispose — gives developers hooks at every stage of a widget's life.
Real-world relevance
In a SaaS collaboration app, a TaskCard showing a task title is a StatelessWidget. A TaskDetailPage with an edit form, character counter, and auto-save timer is a StatefulWidget. The TextEditingController and Timer are created in initState() and disposed in dispose(). The auto-save triggers via setState() only when the text changes.
Key points
- StatelessWidget — Pure Function of Props — StatelessWidget.build() is a pure function: given the same props (constructor parameters), it always returns the same widget tree. No internal state. No side effects. Interview answer: Use StatelessWidget when your UI depends only on its constructor parameters and parent rebuilds.
- StatefulWidget — Widget + Mutable State — StatefulWidget is split into two classes: the Widget (immutable config) and the State (mutable data). The Element owns the State object and keeps it alive across widget rebuilds. When setState() is called, build() runs again with the new state.
- initState() — The Constructor of State — Called ONCE when the State is first inserted into the tree. Use it to: initialize controllers (TextEditingController, AnimationController), subscribe to streams, start timers, call APIs. NEVER call setState() synchronously in initState — it's already about to build.
- didChangeDependencies() — InheritedWidget Changed — Called after initState() AND whenever an InheritedWidget that this widget depends on changes. Example: if your widget uses Theme.of(context) or MediaQuery.of(context), and the theme changes, didChangeDependencies fires. Safe to call context.read() here.
- build() — Called Every Rebuild — Called after initState, didChangeDependencies, setState, and didUpdateWidget. Must be fast and pure — no side effects, no async calls, no heavy computation. Build can be called 60 times per second. Interview trap: putting an API call inside build() causes infinite loops.
- didUpdateWidget() — Parent Rebuilt With New Config — Called when the parent rebuilds and passes new properties to your widget. The old widget is passed as parameter. Example: parent changes the userId prop — didUpdateWidget lets you react: cancel old subscription, start new one. Compare widget.userId vs oldWidget.userId.
- deactivate() — Temporarily Removed — Called when State is removed from the tree temporarily — e.g., navigating to a new route. The State might be re-inserted (didChangeDependencies fires again). Rarely overridden. Don't cancel subscriptions here — wait for dispose().
- dispose() — Permanent Cleanup — Called when State is permanently removed. THIS is where you clean up: controller.dispose(), subscription.cancel(), timer.cancel(), focusNode.dispose(). Forgetting dispose() causes memory leaks. Interview: What resources must be disposed in Flutter?
- setState() Pitfalls — Common mistakes: (1) calling setState() after dispose() — causes 'setState called after dispose' error. (2) calling setState() in build(). (3) mutating state without setState() — UI won't update. (4) expensive work inside setState() callback. Fix (1): check 'if (mounted) setState(() { ... })'.
- When to Choose Which — StatelessWidget: display-only UI, computed from props. StatefulWidget: user interactions (text fields, toggles), animations, local UI state (expanded/collapsed). For business logic state, use BLoC/Riverpod and keep widgets as dumb as possible. Interview: widgets should rarely hold business state.
Code example
// StatelessWidget vs StatefulWidget & Full Lifecycle
import 'dart:async';
import 'package:flutter/material.dart';
// --- STATELESS: Pure function of props ---
class UserAvatar extends StatelessWidget {
final String name;
final String? imageUrl;
final double size;
const UserAvatar({
super.key,
required this.name,
this.imageUrl,
this.size = 40,
});
@override
Widget build(BuildContext context) {
// Given same name + imageUrl + size → always same output
// Safe to call 1000 times per second
if (imageUrl != null) {
return CircleAvatar(
radius: size / 2,
backgroundImage: NetworkImage(imageUrl!),
);
}
return CircleAvatar(
radius: size / 2,
child: Text(name[0].toUpperCase()),
);
}
}
// --- STATEFUL: Full lifecycle demo ---
class TaskNoteEditor extends StatefulWidget {
final String taskId;
final String initialNote;
final void Function(String) onSave;
const TaskNoteEditor({
super.key,
required this.taskId,
required this.initialNote,
required this.onSave,
});
@override
State<TaskNoteEditor> createState() => _TaskNoteEditorState();
}
class _TaskNoteEditorState extends State<TaskNoteEditor> {
late TextEditingController _controller;
late FocusNode _focusNode;
Timer? _autoSaveTimer;
bool _isDirty = false;
int _charCount = 0;
// 1. initState — called ONCE when first inserted
@override
void initState() {
super.initState(); // ALWAYS call super first
_controller = TextEditingController(text: widget.initialNote);
_focusNode = FocusNode();
_charCount = widget.initialNote.length;
_controller.addListener(_onTextChanged);
// Start auto-save every 30 seconds
_autoSaveTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _autoSave(),
);
}
// 2. didChangeDependencies — after initState + when InheritedWidget changes
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Safe to use context here — Theme.of(context), MediaQuery.of(context)
// Called when e.g. theme or locale changes
final theme = Theme.of(context); // OK here
debugPrint('Theme brightness: ${theme.brightness}');
}
// 3. didUpdateWidget — parent rebuilt with new props
@override
void didUpdateWidget(TaskNoteEditor oldWidget) {
super.didUpdateWidget(oldWidget);
// Check if parent passed a different taskId
if (widget.taskId != oldWidget.taskId) {
// Reset editor for the new task
_controller.text = widget.initialNote;
_charCount = widget.initialNote.length;
_isDirty = false;
}
}
// 4. build — called after every setState / rebuild
@override
Widget build(BuildContext context) {
// Keep build() FAST and PURE — no API calls, no heavy logic
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: null,
decoration: InputDecoration(
hintText: 'Add a note...',
suffixIcon: _isDirty
? const Icon(Icons.edit, size: 16)
: const Icon(Icons.check, size: 16),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$_charCount characters',
style: Theme.of(context).textTheme.bodySmall),
TextButton(
onPressed: _isDirty ? _save : null,
child: const Text('Save'),
),
],
),
],
);
}
void _onTextChanged() {
// setState ONLY updates _isDirty and _charCount — minimal rebuild
if (mounted) {
setState(() {
_isDirty = _controller.text != widget.initialNote;
_charCount = _controller.text.length;
});
}
}
void _autoSave() {
if (_isDirty && mounted) _save();
}
void _save() {
widget.onSave(_controller.text);
if (mounted) setState(() => _isDirty = false);
}
// 5. deactivate — temporarily removed (rarely overridden)
@override
void deactivate() {
super.deactivate();
// Widget removed from tree but might come back
}
// 6. dispose — PERMANENT removal — clean up everything
@override
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose(); // Prevents memory leak
_focusNode.dispose(); // Prevents memory leak
_autoSaveTimer?.cancel(); // Prevents timer firing after widget gone
super.dispose(); // ALWAYS call super last
}
}Line-by-line walkthrough
- 1. UserAvatar is StatelessWidget — same props always produces same UI
- 2. const constructor — all instances with same args are identical
- 3. build returns different UI based on imageUrl — pure function
- 4. TaskNoteEditor StatefulWidget holds immutable config: taskId, initialNote, onSave
- 5. createState() returns the mutable State object
- 6. initState: create controller with initial text, create FocusNode, attach listener, start timer
- 7. super.initState() must be called first — framework setup
- 8. didChangeDependencies: safe to access context — Theme, MediaQuery, Provider
- 9. didUpdateWidget: parent gave us a new taskId — reset the editor state
- 10. build is pure: reads from _isDirty and _charCount, no side effects
- 11. _onTextChanged: mounted check prevents setState after dispose
- 12. dispose: remove listener first, then dispose controller and FocusNode, cancel timer
- 13. super.dispose() called LAST — opposite of initState where super is called FIRST
Spot the bug
class TimerWidget extends StatefulWidget {
const TimerWidget({super.key});
@override
State<TimerWidget> createState() => _TimerWidgetState();
}
class _TimerWidgetState extends State<TimerWidget> {
int _seconds = 0;
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
setState(() => _seconds++);
});
}
@override
Widget build(BuildContext context) {
return Text('Elapsed: $_seconds seconds');
}
}Need a hint?
What happens when you navigate away from this screen?
Show answer
dispose() is never overridden. The Timer keeps firing after the widget is gone, calling setState on a disposed State — causing a 'setState called after dispose' exception and a memory leak. Fix: override dispose() and call _timer?.cancel() before super.dispose().
Explain like I'm 5
A StatelessWidget is like a name tag — it shows what you told it and never changes. A StatefulWidget is like a scoreboard — it can change when someone scores. The lifecycle is like the scoreboard's day: it turns on (initState), shows the score (build), updates when someone scores (setState), and turns off at end of day (dispose). You have to turn it off properly or the lights stay on all night (memory leak)!
Fun fact
The 'mounted' check before setState() is one of the most common Flutter interview questions. If you navigate away while an async operation is in progress (like an API call), the State gets disposed. When the future completes and calls setState(), it crashes. The fix: 'if (mounted) setState(() { ... })'. Flutter 3.x improved this with better async patterns, but mounted remains essential.
Hands-on challenge
Build a CountdownTimer widget: takes a duration in seconds, counts down, shows MM:SS, fires an onComplete callback when done. Use initState to start a Timer.periodic, dispose to cancel it, and setState to update the display. Handle the case where the widget is disposed before the timer completes.
More resources
- StatefulWidget Lifecycle (Flutter API)
- StatelessWidget (Flutter API)
- Flutter Widget Lifecycle (Flutter Official)
- Flutter Stateful Widget Lifecycle (Flutter)