Module 7: StatefulWidget and setState
Making Your UI Come Alive
StatelessWidget is a photograph. StatefulWidget is a movie. Today your widgets start moving.
Teach: How to create widgets that change over time using StatefulWidget, State, and setState -- the fundamental mechanism for interactive UIs in Flutter. See: A counter incrementing, toggles switching, and a stopwatch ticking -- all driven by setState. Feel: The "aha" moment when you understand that calling setState() is what makes the screen update.
Everything you've built so far has been static. You arrange widgets, style them, and they sit there looking pretty -- but they don't DO anything. Tap a button? Nothing changes. Wait ten seconds? Same screen. Today that changes. StatefulWidget is Flutter's mechanism for widgets that need to update their appearance over time. A counter that goes up when you tap. A toggle that switches between on and off. A stopwatch that ticks every hundredth of a second. All of these require state that changes, and a way to tell Flutter "hey, something changed, please redraw this part of the screen." That mechanism is setState.
StatelessWidget vs. StatefulWidget
Teach: StatelessWidget is immutable and never changes after being built; StatefulWidget holds a companion State object that can change over time, requiring two classes to work. See: Side-by-side code for both widget types, revealing the two-class structure and why it exists. Feel: That the two-class pattern makes sense once you understand that widgets are disposable but state persists.
Let's start with the mental model. You already know StatelessWidget -- it takes some data, builds some UI, and never changes. Think of it as a printed page. Once it's printed, it stays the same forever. StatefulWidget is different. It's more like a digital display -- the same screen can show different content at different times. The widget itself is still immutable (the frame of the display doesn't change), but it holds a companion State object that CAN change, and that's where the magic happens.
A StatelessWidget is immutable. Once built, it's done:
class Greeting extends StatelessWidget {
final String name;
const Greeting({super.key, required this.name});
@override
Widget build(BuildContext context) {
return Text('Hello, $name!');
}
}
A StatefulWidget can change over time. But notice the structure -- it requires TWO classes:
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Text('Count: $_count');
}
}
Why Two Classes?
This trips up every beginner. Why can't there just be one class?
The widget class (Counter) is immutable and can be rebuilt by Flutter at any time. The state class (_CounterState) persists across those rebuilds. Flutter might destroy and recreate the widget object many times during the app's life, but the state object sticks around.
Think of it this way: the widget is the blueprint for a TV set. The state is what's currently playing on that TV. You can replace the TV (widget gets rebuilt), but the show keeps playing (state persists).
The underscore prefix on _CounterState makes it private to its library file. This is Dart convention for State classes -- nobody outside this file needs to reference the state directly.
setState: The One Function That Makes Things Move
Teach: setState() is the only correct way to tell Flutter that state has changed and the UI needs to rebuild. See: What happens when you call setState vs. when you forget to -- the same code, dramatically different results. Feel: That setState is a simple contract: "I changed something, please redraw."
Here's the most important thing in this entire module:
If you change state without calling setState(), the screen will NOT update.
class _CounterState extends State<Counter> {
int _count = 0;
// WRONG -- screen won't update
void _incrementBroken() {
_count++; // State changed, but Flutter doesn't know!
}
// RIGHT -- screen updates
void _increment() {
setState(() {
_count++;
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $_count', style: TextStyle(fontSize: 48)),
ElevatedButton(
onPressed: _increment,
child: Text('Add'),
),
],
);
}
}
setState() does two things:
1. Runs the callback you give it (where you modify state variables)
2. Tells Flutter to schedule a rebuild of this widget
Here's the contract: you promise to change state inside the callback, and Flutter promises to call your build method again afterward. It's a handshake. Break either side and things go wrong. Change state without setState? The screen is stale. Call setState without changing anything? You waste a rebuild cycle. Keep both sides happy and your UI stays in sync with your data.
Common setState Mistakes
Mistake 1: Forgetting to call setState
void _toggle() {
_isOn = !_isOn; // Changed state but didn't tell Flutter!
}
Mistake 2: Doing async work inside setState
// WRONG -- setState callback must be synchronous
setState(() async {
_data = await fetchData(); // Don't do this!
});
// RIGHT -- do the async work first, then call setState
void _loadData() async {
final data = await fetchData();
setState(() {
_data = data;
});
}
Mistake 3: Calling setState after dispose
If the widget has been removed from the tree, calling setState throws an error. This commonly happens with timers or async callbacks. Check mounted first:
void _delayedUpdate() async {
await Future.delayed(Duration(seconds: 2));
if (mounted) {
setState(() {
_message = 'Updated!';
});
}
}
The golden rule of setState: change state inside the callback, and the callback must be synchronous. If you're doing async work, await it first, THEN call setState with the results.
The Widget Lifecycle
Teach: State objects have a lifecycle -- initState for setup, build for rendering, didUpdateWidget for reacting to parent changes, and dispose for cleanup. See: A complete lifecycle example with a Timer created in initState and cancelled in dispose. Feel: That lifecycle management is predictable and follows a clear sequence you can rely on.
A StatefulWidget's State object has a lifecycle -- it's born, it lives, and eventually it dies. Understanding these lifecycle methods lets you set up resources when the widget appears and clean them up when it disappears. If you've used React, this is similar to useEffect with its cleanup function. If you haven't used React, don't worry -- it's simpler than it sounds.
The State class has several lifecycle methods that Flutter calls at specific moments:
class _MyWidgetState extends State<MyWidget> {
late Timer _timer;
@override
void initState() {
super.initState();
// Called ONCE when State is first inserted into the tree.
// Perfect for: timers, listeners, controllers, one-time setup.
_timer = Timer.periodic(Duration(seconds: 1), (_) {
setState(() {});
});
}
@override
void didUpdateWidget(covariant MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// Called when the PARENT rebuilds with new configuration.
// Compare old and new properties to react to changes.
if (oldWidget.title != widget.title) {
// The title changed -- do something about it
}
}
@override
void dispose() {
// Called ONCE when State is permanently removed from tree.
// MUST clean up: cancel timers, close streams, dispose controllers.
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Called every time setState() is invoked, or parent rebuilds.
return Container();
}
}
The Lifecycle in Order
- Constructor -- the State object is created
- initState() -- called once, right after creation. Set up timers, listeners, etc.
- build() -- called to render the widget. Called many times.
- didUpdateWidget() -- called when parent provides new configuration
- build() -- called again after didUpdateWidget
- ... (steps 3-5 repeat as needed)
- dispose() -- called once when widget is removed forever. Clean up everything.
Accessing Widget Properties from State
Inside your State class, use widget.propertyName to access the StatefulWidget's fields:
class LabeledCounter extends StatefulWidget {
final String label;
const LabeledCounter({super.key, required this.label});
@override
State<LabeledCounter> createState() => _LabeledCounterState();
}
class _LabeledCounterState extends State<LabeledCounter> {
int _count = 0;
@override
Widget build(BuildContext context) {
// widget.label accesses the parent widget's property
return Text('${widget.label}: $_count');
}
}
A Complete StatefulWidget Example
Teach: How all the StatefulWidget pieces fit together in one working example -- two classes, state variables, setState, and conditional rendering. See: A color picker where tapping a circle updates the selected color, the large preview, and the AppBar simultaneously. Feel: That one setState call can trigger multiple visual changes across the entire widget.
Let's put all the pieces together in one place. Here's a color picker that demonstrates the full StatefulWidget pattern -- two classes, state variables, setState, and conditional rendering:
class ColorPicker extends StatefulWidget {
const ColorPicker({super.key});
@override
State<ColorPicker> createState() => _ColorPickerState();
}
class _ColorPickerState extends State<ColorPicker> {
Color _selectedColor = Colors.blue;
final List<Color> _colors = [
Colors.red,
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.teal,
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Pick a Color'),
backgroundColor: _selectedColor,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: _selectedColor,
shape: BoxShape.circle,
),
),
SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: _colors.map((color) {
return GestureDetector(
onTap: () {
setState(() {
_selectedColor = color;
});
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: _selectedColor == color
? Colors.black
: Colors.transparent,
width: 3,
),
),
),
);
}).toList(),
),
],
),
);
}
}
Notice how tapping a color circle calls setState which updates _selectedColor, which causes build to run again, which updates the large circle AND the AppBar color. One state change, multiple visual updates. That's the power of the reactive model.
Spend some time staring at this code. It's small, but it shows you the complete StatefulWidget pattern: a widget class and a State class, a private field holding the current state, a build method that reads that state, and a callback that mutates state inside setState. Tap a circle — setState fires, build runs, and three separate UI elements update because they all read from _selectedColor. You did not manually tell each UI element to change. You changed one variable and Flutter figured out the rest. That is the reactive model in one screen.
When Should You Use StatefulWidget?
Teach: The decision criteria for choosing StatelessWidget vs. StatefulWidget -- start stateless and upgrade only when you need to. See: A clear decision tree that sorts common scenarios into stateless or stateful. Feel: That you won't over-engineer by making everything stateful "just in case."
Not everything needs to be stateful. Here's the decision tree:
Use StatelessWidget when: - The widget's appearance is determined entirely by its constructor parameters - Nothing about this widget changes after it's built - Examples: a styled label, a static card, a formatted price display
Use StatefulWidget when: - The widget needs to change its appearance in response to user interaction - The widget has internal data that evolves over time (counters, timers, form fields) - The widget needs to set up and tear down resources (timers, listeners, controllers)
A good rule of thumb: start with StatelessWidget. If you find yourself needing to change something on screen in response to a tap, a timer, or some other event, THEN convert it to StatefulWidget. Don't make things stateful "just in case." Simpler is better.
There Are No Dumb Questions
Teach: Answers to the most common StatefulWidget questions -- naming conventions, multiple setState calls, super calls, nesting, and initState gotchas. See: Clear, direct answers that prevent common beginner mistakes. Feel: Reassured that these are questions every Flutter beginner asks.
Q: Why does the State class name start with an underscore?
A: The underscore makes it private to its library file. This is Dart convention. No other file should need to create or reference your State class directly -- it's an implementation detail of the widget.
Q: Can I have multiple setState calls in one method?
A: You can, but Flutter batches them into a single rebuild. Calling setState three times in a row doesn't trigger three rebuilds -- just one. So there's no performance benefit to combining them, but it's cleaner to do one setState with all your changes.
Q: What if I forget to call super.initState() or super.dispose()?
A: Flutter will throw an error in debug mode. Always call super.initState() at the beginning of initState, and super.dispose() at the end of dispose. This is a hard requirement.
Q: Can a StatelessWidget contain a StatefulWidget as a child?
A: Absolutely. A StatelessWidget that never changes can contain a StatefulWidget that changes all the time. The parent's build method returns the child, and the child manages its own state independently. This is very common.
Q: What happens if I call setState in initState?
A: Don't. initState runs before the widget is fully mounted, so setState doesn't make sense there. Just set your initial values directly as field initializers or in the initState body without wrapping them in setState.
One more thing that confuses beginners — why two classes? Why not just put everything in one StatefulWidget class? The answer is that Flutter recreates the widget class object every time the parent rebuilds, but keeps the State object alive across rebuilds. That separation is what lets your widget persist its state even when the parent changes. The widget class holds the configuration, the State class holds the mutable data. Understand that split and the whole lifecycle makes sense.
Sharpen Your Pencil: Counter App
Teach: How to apply the core setState pattern with increment, decrement, reset, boundary checking, and conditional styling. See: A counter that changes color as its value increases, with three buttons controlling the count. Feel: That the setState pattern is simple enough to implement from scratch on the first try.
Build a counter application that demonstrates the core setState pattern.
Requirements
- Create a
StatefulWidgetcalledCounterApp - Display the current count centered on screen with
fontSize: 48 - Three buttons in a
Row: - Increment (+) -- increases count by 1
- Decrement (-) -- decreases count by 1
- Reset -- sets count back to 0
- The count must never go below 0
- Change the text color based on value:
- 0: black
- 1-5: green
- 6-10: orange
- Above 10: red
- Wrap in a
Scaffoldwith anAppBartitled"Counter App"
This exercise reinforces: StatefulWidget structure, setState, conditional logic in build.
The counter is the "Hello World" of StatefulWidget. It looks trivial, but notice how it exercises everything — state declaration, three different mutations, a minimum boundary check, and conditional styling that depends on state. If you can build this from memory, you've internalized the whole pattern. The color-by-value requirement matters. It proves that your build method can derive appearance from state programmatically, not just copy state to the screen one-to-one. That's the foundation of every dynamic UI you'll write.
Sharpen Your Pencil: Toggle Panel
Teach: How to manage multiple boolean state variables that each control a different aspect of the UI. See: SwitchListTile widgets toggling visibility, text styling, and background color independently. Feel: That managing several state variables at once is no harder than managing one.
Build a panel of switches that control what appears on screen.
Requirements
- Create a
StatefulWidgetcalledTogglePanel - Three
boolstate variables:showGreeting,useBoldText,darkBackground - Three
SwitchListTilewidgets: "Show Greeting"-- controls visibility of a greeting text"Bold Text"-- toggles bold styling on the greeting"Dark Background"-- togglesScaffoldbackground between white andColors.grey[850]- Conditionally display
"Hello, Flutter!"whenshowGreetingis true, with bold styling whenuseBoldTextis true - All toggles must use
setState()correctly
This exercise reinforces: multiple state variables, conditional rendering, SwitchListTile.
Three boolean variables, three switches, three independent effects. Notice that each switch only cares about its own variable, but the greeting text reads two of them — visibility from one, bold styling from another. That cross-cutting is a preview of what happens in real apps. Multiple UI elements derive from overlapping state. Keep each state variable focused on one concept and let build combine them as needed. This is much cleaner than trying to pre-compute a "ui mode" enum that smashes the variables together.
Sharpen Your Pencil: Stopwatch App
Teach: How to use Timer.periodic with setState for real-time updates, and how to properly clean up timers in dispose. See: A ticking stopwatch display with Start, Stop, and Reset buttons that enable and disable based on running state. Feel: That you can build real-time updating UIs and handle the full widget lifecycle responsibly.
Build a working stopwatch that uses lifecycle methods and a periodic timer.
Requirements
- Create a
StatefulWidgetcalledStopwatchApp - Use
Timer.periodicwith a 10ms interval to update elapsed time - Display time in
MM:SS.mmformat (minutes, seconds, centiseconds) withfontSize: 48and monospace font - Three buttons:
- Start -- begins the timer. Disabled while already running.
- Stop -- pauses the timer. Disabled while not running.
- Reset -- stops timer and resets to zero.
- Do NOT auto-start in initState -- the user starts it with the button
- Cancel the timer in
dispose()to prevent memory leaks - Track elapsed time using a
Stopwatchinstance fromdart:core
This exercise reinforces: Timer.periodic, dispose cleanup, enabled/disabled button states, formatted display.
The stopwatch is the exercise that separates "I understand setState" from "I can build real Flutter apps." Why? Because it forces you to manage an external resource — the Timer — across the full lifecycle. You create it in response to a button tap. You keep it running across many rebuilds. You cancel it when the user hits stop or when the widget unmounts. Forget that last cleanup step and you've got a memory leak that fires setState on a disposed widget, throwing exceptions in production. Timer, StreamSubscription, AnimationController — they all follow this same pattern. Allocate, use, dispose.
Where this fits: StatefulWidget is the foundation for everything interactive in Flutter. Every form, every animation, every dynamic list, every real-time update -- they all build on what you learned today. The pattern is always the same: store mutable data in State, modify it inside setState(), and let build() reflect the current state.
📝 Module Quiz
Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.
Wrapping Up
Teach: A summary of the StatefulWidget pattern -- store state, modify it inside setState, let build reflect the current state -- and why it matters for everything ahead. See: The complete picture: two classes, lifecycle methods, setState, and the reactive rebuild cycle. Feel: That you have crossed a major threshold from static UIs to living, interactive applications.
You've crossed a major threshold. Your widgets are no longer static pictures -- they're living, changing, interactive pieces of UI. The pattern you learned today -- store state in a State class, modify it inside setState, let build reflect the current state -- is the foundation of every interactive Flutter app. The stopwatch exercise proves you can handle the full lifecycle: setting up timers, updating state, and cleaning up when you're done. Hold on to that pattern. You're going to use it in every module from here on out.
The setState pattern in three words: mutate, notify, rebuild. Change your state variables, call setState to notify Flutter, and your build method rebuilds the UI to match. That's the whole dance.