Lesson 11 of 77 beginner

Stateless vs Stateful Widget & Lifecycle

The Question Every Flutter Interview Starts With

Open interactive version (quiz + challenge)

Real-world analogy

A StatelessWidget is like a printed photograph — it shows exactly what it was told to show, and never changes. A StatefulWidget is like a digital photo frame — it can update its display when new data arrives. The lifecycle methods are the frame's built-in events: power on (initState), display update (build), photo changed (didUpdateWidget), unplugged (dispose).

What is it?

StatelessWidget is an immutable widget whose UI depends solely on its constructor parameters. StatefulWidget pairs an immutable widget with a mutable State object that persists across rebuilds. The lifecycle — initState, didChangeDependencies, build, didUpdateWidget, deactivate, dispose — gives developers hooks at every stage of a widget's life.

Real-world relevance

In a SaaS collaboration app, a TaskCard showing a task title is a StatelessWidget. A TaskDetailPage with an edit form, character counter, and auto-save timer is a StatefulWidget. The TextEditingController and Timer are created in initState() and disposed in dispose(). The auto-save triggers via setState() only when the text changes.

Key points

Code example

// StatelessWidget vs StatefulWidget & Full Lifecycle

import 'dart:async';
import 'package:flutter/material.dart';

// --- STATELESS: Pure function of props ---
class UserAvatar extends StatelessWidget {
  final String name;
  final String? imageUrl;
  final double size;

  const UserAvatar({
    super.key,
    required this.name,
    this.imageUrl,
    this.size = 40,
  });

  @override
  Widget build(BuildContext context) {
    // Given same name + imageUrl + size → always same output
    // Safe to call 1000 times per second
    if (imageUrl != null) {
      return CircleAvatar(
        radius: size / 2,
        backgroundImage: NetworkImage(imageUrl!),
      );
    }
    return CircleAvatar(
      radius: size / 2,
      child: Text(name[0].toUpperCase()),
    );
  }
}

// --- STATEFUL: Full lifecycle demo ---
class TaskNoteEditor extends StatefulWidget {
  final String taskId;
  final String initialNote;
  final void Function(String) onSave;

  const TaskNoteEditor({
    super.key,
    required this.taskId,
    required this.initialNote,
    required this.onSave,
  });

  @override
  State<TaskNoteEditor> createState() => _TaskNoteEditorState();
}

class _TaskNoteEditorState extends State<TaskNoteEditor> {
  late TextEditingController _controller;
  late FocusNode _focusNode;
  Timer? _autoSaveTimer;
  bool _isDirty = false;
  int _charCount = 0;

  // 1. initState — called ONCE when first inserted
  @override
  void initState() {
    super.initState(); // ALWAYS call super first
    _controller = TextEditingController(text: widget.initialNote);
    _focusNode = FocusNode();
    _charCount = widget.initialNote.length;

    _controller.addListener(_onTextChanged);

    // Start auto-save every 30 seconds
    _autoSaveTimer = Timer.periodic(
      const Duration(seconds: 30),
      (_) => _autoSave(),
    );
  }

  // 2. didChangeDependencies — after initState + when InheritedWidget changes
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Safe to use context here — Theme.of(context), MediaQuery.of(context)
    // Called when e.g. theme or locale changes
    final theme = Theme.of(context); // OK here
    debugPrint('Theme brightness: ${theme.brightness}');
  }

  // 3. didUpdateWidget — parent rebuilt with new props
  @override
  void didUpdateWidget(TaskNoteEditor oldWidget) {
    super.didUpdateWidget(oldWidget);
    // Check if parent passed a different taskId
    if (widget.taskId != oldWidget.taskId) {
      // Reset editor for the new task
      _controller.text = widget.initialNote;
      _charCount = widget.initialNote.length;
      _isDirty = false;
    }
  }

  // 4. build — called after every setState / rebuild
  @override
  Widget build(BuildContext context) {
    // Keep build() FAST and PURE — no API calls, no heavy logic
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          controller: _controller,
          focusNode: _focusNode,
          maxLines: null,
          decoration: InputDecoration(
            hintText: 'Add a note...',
            suffixIcon: _isDirty
                ? const Icon(Icons.edit, size: 16)
                : const Icon(Icons.check, size: 16),
          ),
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text('$_charCount characters',
                style: Theme.of(context).textTheme.bodySmall),
            TextButton(
              onPressed: _isDirty ? _save : null,
              child: const Text('Save'),
            ),
          ],
        ),
      ],
    );
  }

  void _onTextChanged() {
    // setState ONLY updates _isDirty and _charCount — minimal rebuild
    if (mounted) {
      setState(() {
        _isDirty = _controller.text != widget.initialNote;
        _charCount = _controller.text.length;
      });
    }
  }

  void _autoSave() {
    if (_isDirty && mounted) _save();
  }

  void _save() {
    widget.onSave(_controller.text);
    if (mounted) setState(() => _isDirty = false);
  }

  // 5. deactivate — temporarily removed (rarely overridden)
  @override
  void deactivate() {
    super.deactivate();
    // Widget removed from tree but might come back
  }

  // 6. dispose — PERMANENT removal — clean up everything
  @override
  void dispose() {
    _controller.removeListener(_onTextChanged);
    _controller.dispose(); // Prevents memory leak
    _focusNode.dispose();  // Prevents memory leak
    _autoSaveTimer?.cancel(); // Prevents timer firing after widget gone
    super.dispose(); // ALWAYS call super last
  }
}

Line-by-line walkthrough

  1. 1. UserAvatar is StatelessWidget — same props always produces same UI
  2. 2. const constructor — all instances with same args are identical
  3. 3. build returns different UI based on imageUrl — pure function
  4. 4. TaskNoteEditor StatefulWidget holds immutable config: taskId, initialNote, onSave
  5. 5. createState() returns the mutable State object
  6. 6. initState: create controller with initial text, create FocusNode, attach listener, start timer
  7. 7. super.initState() must be called first — framework setup
  8. 8. didChangeDependencies: safe to access context — Theme, MediaQuery, Provider
  9. 9. didUpdateWidget: parent gave us a new taskId — reset the editor state
  10. 10. build is pure: reads from _isDirty and _charCount, no side effects
  11. 11. _onTextChanged: mounted check prevents setState after dispose
  12. 12. dispose: remove listener first, then dispose controller and FocusNode, cancel timer
  13. 13. super.dispose() called LAST — opposite of initState where super is called FIRST

Spot the bug

class TimerWidget extends StatefulWidget {
  const TimerWidget({super.key});
  @override
  State<TimerWidget> createState() => _TimerWidgetState();
}

class _TimerWidgetState extends State<TimerWidget> {
  int _seconds = 0;
  Timer? _timer;

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 1), (t) {
      setState(() => _seconds++);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Text('Elapsed: $_seconds seconds');
  }
}
Need a hint?
What happens when you navigate away from this screen?
Show answer
dispose() is never overridden. The Timer keeps firing after the widget is gone, calling setState on a disposed State — causing a 'setState called after dispose' exception and a memory leak. Fix: override dispose() and call _timer?.cancel() before super.dispose().

Explain like I'm 5

A StatelessWidget is like a name tag — it shows what you told it and never changes. A StatefulWidget is like a scoreboard — it can change when someone scores. The lifecycle is like the scoreboard's day: it turns on (initState), shows the score (build), updates when someone scores (setState), and turns off at end of day (dispose). You have to turn it off properly or the lights stay on all night (memory leak)!

Fun fact

The 'mounted' check before setState() is one of the most common Flutter interview questions. If you navigate away while an async operation is in progress (like an API call), the State gets disposed. When the future completes and calls setState(), it crashes. The fix: 'if (mounted) setState(() { ... })'. Flutter 3.x improved this with better async patterns, but mounted remains essential.

Hands-on challenge

Build a CountdownTimer widget: takes a duration in seconds, counts down, shows MM:SS, fires an onComplete callback when done. Use initState to start a Timer.periodic, dispose to cancel it, and setState to update the display. Handle the case where the widget is disposed before the timer completes.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery