Module 7: StatefulWidget and setState

Making Your UI Come Alive

Building UI Screens

A widget before and after setState 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

  1. Constructor -- the State object is created
  2. initState() -- called once, right after creation. Set up timers, listeners, etc.
  3. build() -- called to render the widget. Called many times.
  4. didUpdateWidget() -- called when parent provides new configuration
  5. build() -- called again after didUpdateWidget
  6. ... (steps 3-5 repeat as needed)
  7. 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

  1. Create a StatefulWidget called CounterApp
  2. Display the current count centered on screen with fontSize: 48
  3. Three buttons in a Row:
  4. Increment (+) -- increases count by 1
  5. Decrement (-) -- decreases count by 1
  6. Reset -- sets count back to 0
  7. The count must never go below 0
  8. Change the text color based on value:
  9. 0: black
  10. 1-5: green
  11. 6-10: orange
  12. Above 10: red
  13. Wrap in a Scaffold with an AppBar titled "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

  1. Create a StatefulWidget called TogglePanel
  2. Three bool state variables: showGreeting, useBoldText, darkBackground
  3. Three SwitchListTile widgets:
  4. "Show Greeting" -- controls visibility of a greeting text
  5. "Bold Text" -- toggles bold styling on the greeting
  6. "Dark Background" -- toggles Scaffold background between white and Colors.grey[850]
  7. Conditionally display "Hello, Flutter!" when showGreeting is true, with bold styling when useBoldText is true
  8. 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

  1. Create a StatefulWidget called StopwatchApp
  2. Use Timer.periodic with a 10ms interval to update elapsed time
  3. Display time in MM:SS.mm format (minutes, seconds, centiseconds) with fontSize: 48 and monospace font
  4. Three buttons:
  5. Start -- begins the timer. Disabled while already running.
  6. Stop -- pauses the timer. Disabled while not running.
  7. Reset -- stops timer and resets to zero.
  8. Do NOT auto-start in initState -- the user starts it with the button
  9. Cancel the timer in dispose() to prevent memory leaks
  10. Track elapsed time using a Stopwatch instance from dart: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.

1 / 1