Lesson 32 of 51 intermediate

Understanding State

The memory that makes your app alive

Open interactive version (quiz + challenge)

Real-world analogy

Think of your app as a puppet show. The puppets on stage are the UI -- what the audience sees. But behind the curtain, there is a puppeteer pulling strings -- that is the state. Every time the puppeteer moves a string, the puppet changes position. In Flutter, every time state changes, the UI rebuilds to reflect the new reality. The audience never sees the strings, just the smooth performance.

What is it?

State is the data that your app holds in memory at any given moment which determines what the UI looks like. When state changes, Flutter rebuilds the affected widgets to reflect the new state. There are two kinds: ephemeral UI state that lives in a single widget, and app state that is shared across the application. Professional Flutter apps use reactive patterns where the UI automatically reacts to state changes through streams and state management libraries like BLoC.

Real-world relevance

Every interactive app is driven by state. Instagram's feed is state (a list of posts). The like count on a post is state. Whether you are on the home tab or profile tab is state. Managing state well means your app is predictable, testable, and maintainable. Managing it poorly leads to bugs where the UI shows stale data, buttons that do nothing, or screens that flicker.

Key points

Code example

import 'dart:async';

// Immutable state class
class TodoState {
  final List<String> todos;
  final bool isLoading;
  final String? error;

  const TodoState({
    this.todos = const [],
    this.isLoading = false,
    this.error,
  });

  TodoState copyWith({
    List<String>? todos,
    bool? isLoading,
    String? error,
  }) {
    return TodoState(
      todos: todos ?? this.todos,
      isLoading: isLoading ?? this.isLoading,
      error: error,
    );
  }

  @override
  String toString() =>
    'TodoState(todos: ${todos.length}, '
    'loading: $isLoading, error: $error)';
}

// Simple reactive store using StreamController
class TodoStore {
  TodoState _state = const TodoState();
  final _controller = StreamController<TodoState>.broadcast();

  Stream<TodoState> get stream => _controller.stream;
  TodoState get current => _state;

  void _emit(TodoState newState) {
    _state = newState;
    _controller.add(newState);
  }

  Future<void> loadTodos() async {
    _emit(_state.copyWith(isLoading: true, error: null));
    try {
      await Future.delayed(const Duration(seconds: 1));
      final todos = ['Buy groceries', 'Learn Flutter', 'Build app'];
      _emit(_state.copyWith(todos: todos, isLoading: false));
    } catch (e) {
      _emit(_state.copyWith(
        isLoading: false,
        error: e.toString(),
      ));
    }
  }

  void addTodo(String todo) {
    final updated = [..._state.todos, todo];
    _emit(_state.copyWith(todos: updated));
  }

  void removeTodo(int index) {
    final updated = [..._state.todos]..removeAt(index);
    _emit(_state.copyWith(todos: updated));
  }

  void dispose() {
    _controller.close();
  }
}

void main() async {
  final store = TodoStore();

  store.stream.listen((state) {
    print('State changed: $state');
  });

  await store.loadTodos();
  store.addTodo('Write tests');
  store.removeTodo(0);

  store.dispose();
}

Line-by-line walkthrough

  1. 1. Define an immutable TodoState class with three fields: a list of todos, a loading flag, and an optional error message.
  2. 2. The constructor uses const and provides default values so you can create empty states easily.
  3. 3. The copyWith method creates a new TodoState, replacing only the fields you pass while keeping the rest unchanged.
  4. 4. Override toString so printing the state gives a readable summary of todos count, loading, and error.
  5. 5. TodoStore is our simple reactive store. It holds a private _state and a broadcast StreamController.
  6. 6. Expose the stream for listeners and a current getter for synchronous access to the latest state.
  7. 7. The _emit method updates the internal state and pushes the new state into the stream.
  8. 8. loadTodos sets loading to true and clears any previous error before starting the async operation.
  9. 9. After a simulated delay, it emits a new state with the loaded todos and loading set to false.
  10. 10. If the try block throws, the catch block emits a state with loading false and the error message.
  11. 11. addTodo creates a new list by spreading existing todos and adding the new one, then emits.
  12. 12. removeTodo creates a copy of the list, removes the item at the given index, then emits the updated state.
  13. 13. dispose closes the StreamController to prevent memory leaks.
  14. 14. In main, we create a store and subscribe to its stream, printing every state change.
  15. 15. We call loadTodos (which is async), add a todo, and remove one, then dispose the store.

Spot the bug

class CounterStore {
  int _count = 0;
  final _controller = StreamController<int>();

  Stream<int> get stream => _controller.stream;

  void increment() {
    _count++;
    _controller.add(_count);
  }

  void dispose() {
    _controller.close();
  }
}

void main() {
  final store = CounterStore();
  store.stream.listen((c) => print('A: $c'));
  store.stream.listen((c) => print('B: $c'));
  store.increment();
}
Need a hint?
What happens when two listeners try to subscribe to a regular StreamController?
Show answer
A regular StreamController only supports one listener. The second listen call will throw a 'Stream has already been listened to' error. Fix it by using StreamController<int>.broadcast() so multiple listeners can subscribe to the same stream.

Explain like I'm 5

Imagine you have a whiteboard in your room. Everything written on it is the 'state' of your room. If you erase 'clean room' and write 'messy room,' anyone who walks in sees the updated whiteboard. In an app, the whiteboard is the state, and the room is the screen. When you change what is on the whiteboard, the screen automatically updates to match. The cool part is you never have to tell the screen to change -- it just watches the whiteboard and redraws itself whenever something changes!

Fun fact

The concept of reactive programming dates back to a 1997 paper on Functional Reactive Programming (FRP) by Conal Elliott and Paul Hudak. Flutter's entire rendering philosophy -- declaring what the UI should look like for a given state and letting the framework figure out the changes -- is a direct descendant of these ideas.

Hands-on challenge

Build a simple reactive counter store (without Flutter, just Dart) that uses a StreamController. The store should support increment, decrement, and reset operations. Each operation should emit a new immutable CounterState with the updated count and a timestamp of when the change happened. Write a main function that subscribes to the stream, performs several operations, and prints each state change.

More resources

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