StatelessWidget vs StatefulWidget: what changes and what doesn’t
In Flutter, the UI is rebuilt often. The key question is: does this widget need to remember something that can change while the app is running?
StatelessWidget
- Use when the widget’s output depends only on the incoming configuration (constructor parameters) and inherited data (like Theme).
- No mutable state stored inside the widget instance.
- If something changes, the parent must rebuild it with new inputs.
StatefulWidget
- Use when the widget needs to hold mutable values that change over time (counter value, toggle on/off, selected tab, loading flag).
- A
StatefulWidgetis paired with a separateStateobject that holds the mutable fields. - When state changes, you call
setStateto tell Flutter to rebuild that widget subtree.
How setState triggers rebuilds (and what it actually does)
setState(() { ... }) does two things:
- Runs your callback immediately, where you update your state variables.
- Marks the current
Stateobject as “dirty”, scheduling a rebuild for the next frame. Flutter then callsbuildagain and updates what changed.
setState does not “redraw the whole app”; it rebuilds the widget subtree under that State. Flutter then efficiently updates the rendered output.
Refactor a static UI into an interactive one: Counter
Start with a static screen that shows a number but doesn’t change. This is a common first refactor from StatelessWidget to StatefulWidget.
Step 1: Static version (StatelessWidget)
import 'package:flutter/material.dart';
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: const Center(
child: Text(
'0',
style: TextStyle(fontSize: 48),
),
),
);
}
}This is fine if the number never changes. But a counter is meant to change based on user actions, so we need state.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
Step 2: Convert to StatefulWidget
In a StatefulWidget, the widget class is mostly a shell; the mutable fields live in the State class.
import 'package:flutter/material.dart';
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Text(
'$_count',
style: const TextStyle(fontSize: 48),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Icon(Icons.add),
),
);
}
}What changed?
_countis stored in theStateobject.- When the button is pressed, we update
_countinsidesetState. - Flutter calls
buildagain, and the text shows the new value.
Step 3: Counter variations (increment, decrement, reset)
As the UI grows, keep the state updates small and predictable. A good pattern is to move logic into methods and call them from handlers.
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
void _increment() {
setState(() {
_count++;
});
}
void _decrement() {
setState(() {
_count--;
});
}
void _reset() {
setState(() {
_count = 0;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter Variations')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$_count', style: const TextStyle(fontSize: 48)),
const SizedBox(height: 16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(onPressed: _decrement, icon: const Icon(Icons.remove)),
const SizedBox(width: 12),
IconButton(onPressed: _increment, icon: const Icon(Icons.add)),
const SizedBox(width: 12),
OutlinedButton(onPressed: _reset, child: const Text('Reset')),
],
),
],
),
),
);
}
}This keeps build focused on describing UI, while methods handle state transitions.
Refactor a static UI into an interactive one: Toggle
Toggles are a great example of boolean state. Start with a label that always says “Off”, then make it interactive.
Step-by-step: Switch with state
import 'package:flutter/material.dart';
class TogglePage extends StatefulWidget {
const TogglePage({super.key});
@override
State<TogglePage> createState() => _TogglePageState();
}
class _TogglePageState extends State<TogglePage> {
bool _isOn = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Toggle')),
body: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: _isOn,
onChanged: (value) {
setState(() {
_isOn = value;
});
},
),
const SizedBox(width: 12),
Text(_isOn ? 'On' : 'Off'),
],
),
),
);
}
}Notice the pattern: the widget reads from state (value: _isOn), and user interaction updates state (onChanged calls setState).
Refactor a static UI into an interactive one: Favorite button
A “favorite” (heart/star) is another boolean state, but it also demonstrates how state can affect styling and icons.
Step-by-step: Icon toggle
import 'package:flutter/material.dart';
class FavoriteCard extends StatefulWidget {
const FavoriteCard({super.key});
@override
State<FavoriteCard> createState() => _FavoriteCardState();
}
class _FavoriteCardState extends State<FavoriteCard> {
bool _isFavorite = false;
void _toggleFavorite() {
setState(() {
_isFavorite = !_isFavorite;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Favorite')),
body: Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _toggleFavorite,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? Colors.red : null,
),
),
const SizedBox(width: 8),
Text(_isFavorite ? 'Saved' : 'Save'),
],
),
),
),
),
);
}
}This is the same state pattern again, which is a good sign: once you understand setState, many interactive UIs become straightforward.
What should be stored in state (and what shouldn’t)
Store in state only what must persist across rebuilds and can change over time.
Good candidates for state
- User-driven values: counters, toggles, selected index, expanded/collapsed flags.
- UI status: loading flags, error flags, “is submitting” booleans.
- Temporary input-related values when needed by the widget: e.g., a selected date, a filter choice.
Usually not state
- Values that can be derived from other state: if you can compute it in
build, don’t store a duplicate. - Constants and styling: keep them as
constwidgets or final values. - Data that belongs higher in the tree: if multiple widgets need the same value, lifting state up is often better than duplicating it.
Example of derived value: instead of storing both _count and _isEven, store only _count and compute final isEven = _count.isEven; in build.
Keep build methods clean
build should be easy to scan: it describes UI, not business logic. A few practical techniques help a lot.
1) Move state changes into methods
Instead of writing complex logic inside button handlers, create methods like _increment, _toggleFavorite, _reset. This makes the widget easier to test and maintain.
2) Extract widgets for repeated UI
If a section of UI grows, extract it into a separate widget. If it doesn’t need to manage its own state, make it a StatelessWidget and pass values/callbacks in.
class CounterControls extends StatelessWidget {
final VoidCallback onDecrement;
final VoidCallback onIncrement;
final VoidCallback onReset;
const CounterControls({
super.key,
required this.onDecrement,
required this.onIncrement,
required this.onReset,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(onPressed: onDecrement, icon: const Icon(Icons.remove)),
IconButton(onPressed: onIncrement, icon: const Icon(Icons.add)),
OutlinedButton(onPressed: onReset, child: const Text('Reset')),
],
);
}
}Your stateful page stays focused on state, while the extracted widget focuses on layout.
3) Prefer const where possible
Mark widgets as const when their inputs are compile-time constants. This reduces rebuild work and clarifies what is truly dynamic.
Common mistakes with setState (and how to avoid them)
1) Updating state during build
Never call setState inside build. It can create an infinite rebuild loop because setState schedules another build.
Problematic pattern:
@override
Widget build(BuildContext context) {
setState(() {
_count = 0;
});
return Text('$_count');
}Fix: initialize state in initState (for one-time setup) or update state in response to user actions and async callbacks.
2) Mutating state without setState
If you change a state field but don’t call setState, the UI won’t rebuild, so the screen won’t reflect the new value.
void _incrementWrong() {
_count++; // UI will not update
}Fix:
void _increment() {
setState(() {
_count++;
});
}3) Doing expensive work inside setState
setState should be small: update fields only. Heavy computation should happen outside, then you store the result inside setState.
void _updateWithWork() {
final computed = List.generate(100000, (i) => i).reduce((a, b) => a + b);
setState(() {
_count = computed;
});
}4) Calling setState after dispose
If you start an async operation and the user leaves the screen before it finishes, calling setState afterward can throw an error because the State is no longer mounted.
Future<void> _load() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
setState(() {
_isOn = true;
});
}5) Storing UI objects in state
Avoid storing widgets, colors, or text widgets as state. Store the minimal data (like bool _isFavorite) and let build decide which widget to show.
Mental model: state drives UI
When using StatefulWidget, aim for this flow:
- State variables represent the current UI situation.
- The
buildmethod reads state and returns widgets. - User actions (or async results) update state inside
setState. - Flutter rebuilds, and the UI reflects the new state.