Lesson 18 of 51 beginner

StatefulWidget & setState

Widgets That Remember and React

Open interactive version (quiz + challenge)

Real-world analogy

A StatefulWidget is like a whiteboard in a classroom. The board frame (the widget) stays the same, but the content written on it (the state) can be erased and rewritten anytime. When a teacher writes new information (setState), the students (Flutter framework) see the update and react. In team_mvp_kit, StatefulWidgets are used for local UI state like form inputs, animations, and tab selections -- while bigger state lives in BLoC.

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

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. 1. Import Flutter material library
  2. 2. Define TodoItem as a StatefulWidget with title, initialCompleted, and onToggle callback
  3. 3. Title is the todo text, initialCompleted is the starting state, onToggle notifies the parent
  4. 4. Const constructor with named parameters
  5. 5. createState returns _TodoItemState -- the mutable companion
  6. 6. _TodoItemState uses SingleTickerProviderStateMixin for animation support
  7. 7. Declare _isCompleted bool for the toggle state
  8. 8. Declare AnimationController and Animation for the tap effect
  9. 9. initState: call super first, then initialize state from widget.initialCompleted
  10. 10. Create the AnimationController with 200ms duration and vsync from the mixin
  11. 11. Create a scale animation that shrinks to 95% and bounces back
  12. 12. dispose: dispose the animation controller to prevent memory leaks, then call super
  13. 13. _toggle method: call setState to flip _isCompleted
  14. 14. Notify the parent via widget.onToggle callback with the new value
  15. 15. Play the scale animation forward then reverse for a satisfying tap effect
  16. 16. build method: get the current theme
  17. 17. Return ScaleTransition wrapping the card for the animation
  18. 18. Card contains a ListTile for clean list-item layout
  19. 19. Leading Checkbox bound to _isCompleted, onChanged calls _toggle
  20. 20. Title Text shows widget.title with strikethrough decoration when completed
  21. 21. Completed text gets reduced opacity for a faded effect
  22. 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?
The count is increasing (you can see it in the print statement) but the UI does not update. Why?
Show answer
The onPressed callback increments count directly without calling setState. Flutter does not know the state has changed, so it never rebuilds the widget. Fix: wrap the increment in setState(() { count++; }); so Flutter schedules a rebuild and the Text widget shows the new count.

Explain like I'm 5

Remember the whiteboard analogy? A StatelessWidget is a poster -- print it once and it stays the same forever. A StatefulWidget is a whiteboard. The frame (the widget class) stays on the wall, but the writing (the State class) can change. When you erase something and write new words (setState), everyone in the room sees the update. initState is like writing the first thing on the board when class starts. dispose is like erasing the board when class ends. You only use a whiteboard when you actually need to change what is written -- if the content never changes, just use a poster (StatelessWidget).

Fun fact

Flutter's State objects outlive their StatefulWidget instances. When a parent rebuilds and creates a new StatefulWidget, Flutter reuses the existing State object and just updates its widget reference. This is why the State class has a widget getter -- it always points to the latest widget. The State object only dies when the widget is removed from the tree entirely.

Hands-on challenge

Build a StopWatch widget using StatefulWidget. It should have: (1) a text display showing elapsed seconds with one decimal place, (2) a Start/Stop toggle button, (3) a Reset button that appears only when the stopwatch is running or has a non-zero time. Use Timer.periodic in initState (started conditionally) and clean it up in dispose. Use setState to update the display every 100 milliseconds.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart