StatefulWidget & setState
Widgets That Remember and React
Open interactive version (quiz + challenge)Real-world analogy
What is it?
A StatefulWidget is a widget that can change over time. It is split into two objects: an immutable widget (created fresh each rebuild) and a mutable State object (persists across rebuilds). The State object holds mutable variables and a build method. When you call setState(), Flutter marks the widget as dirty and schedules a rebuild. On the next frame, build() runs with the updated state, and Flutter efficiently patches the UI. StatefulWidgets are essential for any UI element that responds to user interaction.
Real-world relevance
In team_mvp_kit, StatefulWidgets handle local UI concerns. A login form uses StatefulWidget to manage TextEditingControllers and toggle password visibility. A settings screen uses it for switch toggles. Animation controllers require StatefulWidget because they need initState for setup and dispose for cleanup. However, the project minimizes StatefulWidgets by moving most state into BLoC -- screens are often StatelessWidgets wrapping BlocBuilder, with only truly local state (like scroll position) in StatefulWidgets.
Key points
- StatefulWidget Structure — A StatefulWidget is split into two classes: the widget class (immutable, like a StatelessWidget) and a State class (mutable, holds the changing data). The widget creates its state via createState().
- setState: Triggering Rebuilds — Call setState to tell Flutter that state has changed and the UI needs to update. Pass a callback function that modifies the state variables. Flutter then calls build again with the new values.
- initState: One-Time Setup — Override initState to run code once when the state object is first created. Use it for initializing controllers, starting timers, or subscribing to streams. Always call super.initState() first.
- dispose: Cleanup — Override dispose to clean up resources when the widget is removed from the tree. Cancel timers, close streams, dispose controllers. Always call super.dispose() last. Forgetting dispose causes memory leaks.
- didUpdateWidget: Reacting to Parent Changes — Called when the parent widget rebuilds and passes new configuration. Compare old and new widget properties to decide if state needs updating. Use this instead of recreating the whole state.
- Accessing Widget Properties from State — Inside the State class, use widget.propertyName to access the StatefulWidget's constructor parameters. The widget property always points to the current widget instance.
- When to Use StatefulWidget — Use StatefulWidget for local, ephemeral UI state: toggle switches, form inputs, animations, scroll positions, tab indices. For app-wide or shared state, use BLoC, Provider, or other state management solutions.
- TextEditingController Example — A common use of StatefulWidget is managing text input. The TextEditingController holds the current text value and must be disposed when the widget is removed.
- setState Rules — Never call setState after dispose (check mounted first). Never call setState inside build. Keep the setState callback synchronous -- do async work before calling setState with the result. Minimize what changes inside setState for clarity.
Code example
import 'package:flutter/material.dart';
class TodoItem extends StatefulWidget {
final String title;
final bool initialCompleted;
final ValueChanged<bool> onToggle;
const TodoItem({
super.key,
required this.title,
this.initialCompleted = false,
required this.onToggle,
});
@override
State<TodoItem> createState() => _TodoItemState();
}
class _TodoItemState extends State<TodoItem>
with SingleTickerProviderStateMixin {
late bool _isCompleted;
late AnimationController _animController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_isCompleted = widget.initialCompleted;
_animController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _animController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_isCompleted = !_isCompleted;
});
widget.onToggle(_isCompleted);
_animController.forward().then((_) => _animController.reverse());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ScaleTransition(
scale: _scaleAnimation,
child: Card(
child: ListTile(
leading: Checkbox(
value: _isCompleted,
onChanged: (_) => _toggle(),
),
title: Text(
widget.title,
style: TextStyle(
decoration: _isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
color: _isCompleted
? theme.colorScheme.onSurface.withOpacity(0.5)
: theme.colorScheme.onSurface,
),
),
),
),
);
}
}Line-by-line walkthrough
- 1. Import Flutter material library
- 2. Define TodoItem as a StatefulWidget with title, initialCompleted, and onToggle callback
- 3. Title is the todo text, initialCompleted is the starting state, onToggle notifies the parent
- 4. Const constructor with named parameters
- 5. createState returns _TodoItemState -- the mutable companion
- 6. _TodoItemState uses SingleTickerProviderStateMixin for animation support
- 7. Declare _isCompleted bool for the toggle state
- 8. Declare AnimationController and Animation for the tap effect
- 9. initState: call super first, then initialize state from widget.initialCompleted
- 10. Create the AnimationController with 200ms duration and vsync from the mixin
- 11. Create a scale animation that shrinks to 95% and bounces back
- 12. dispose: dispose the animation controller to prevent memory leaks, then call super
- 13. _toggle method: call setState to flip _isCompleted
- 14. Notify the parent via widget.onToggle callback with the new value
- 15. Play the scale animation forward then reverse for a satisfying tap effect
- 16. build method: get the current theme
- 17. Return ScaleTransition wrapping the card for the animation
- 18. Card contains a ListTile for clean list-item layout
- 19. Leading Checkbox bound to _isCompleted, onChanged calls _toggle
- 20. Title Text shows widget.title with strikethrough decoration when completed
- 21. Completed text gets reduced opacity for a faded effect
- 22. Non-completed text uses full surface color
Spot the bug
class ClickCounter extends StatefulWidget {
@override
State<ClickCounter> createState() => _ClickCounterState();
}
class _ClickCounterState extends State<ClickCounter> {
int count = 0;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
count++;
print('Count: $count');
},
child: Text('Tapped $count times'),
);
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- StatefulWidget Class (api.flutter.dev)
- State Class Lifecycle (api.flutter.dev)
- Adding Interactivity (docs.flutter.dev)