Understanding State
The memory that makes your app alive
Open interactive version (quiz + challenge)Real-world analogy
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
- What Is State? — State is any data that can change over time and affects what the UI displays. A counter value, a list of todos, whether a checkbox is checked -- all of these are state.
- UI State vs App State — UI state (ephemeral state) is local to a single widget -- like whether a text field has focus or a panel is expanded. App state is shared across multiple widgets -- like the logged-in user or a shopping cart.
- StatefulWidget Recap — StatefulWidget holds mutable state in its State object. Calling setState triggers a rebuild of that widget and its descendants.
- The Problem with setState at Scale — As apps grow, setState becomes problematic. State gets scattered across many widgets, becomes hard to test, and passing data through deep widget trees leads to prop drilling.
- Reactive Programming Basics — Reactive programming means your UI reacts to state changes automatically. Instead of manually telling each widget to update, you declare what the UI should look like for any given state, and the framework handles the rest.
- Streams: The River of Data — Dart Streams are sequences of asynchronous data events. They are the backbone of reactive patterns in Flutter. BLoC pattern is built entirely on Streams.
- StreamController for Custom Streams — StreamController lets you create streams that you can manually add data to. This is the foundation of how BLoC works under the hood -- events go in, states come out.
- Immutable State — In professional Flutter apps, state objects should be immutable. Instead of modifying state in place, you create a new state object with the changes. This makes state predictable, debuggable, and safe for comparison.
- When Does Flutter Rebuild? — Flutter rebuilds a widget when setState is called, when an InheritedWidget it depends on changes, or when its parent rebuilds and passes new data. Understanding rebuild triggers helps you write efficient UIs.
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. Define an immutable TodoState class with three fields: a list of todos, a loading flag, and an optional error message.
- 2. The constructor uses const and provides default values so you can create empty states easily.
- 3. The copyWith method creates a new TodoState, replacing only the fields you pass while keeping the rest unchanged.
- 4. Override toString so printing the state gives a readable summary of todos count, loading, and error.
- 5. TodoStore is our simple reactive store. It holds a private _state and a broadcast StreamController.
- 6. Expose the stream for listeners and a current getter for synchronous access to the latest state.
- 7. The _emit method updates the internal state and pushes the new state into the stream.
- 8. loadTodos sets loading to true and clears any previous error before starting the async operation.
- 9. After a simulated delay, it emits a new state with the loaded todos and loading set to false.
- 10. If the try block throws, the catch block emits a state with loading false and the error message.
- 11. addTodo creates a new list by spreading existing todos and adding the new one, then emits.
- 12. removeTodo creates a copy of the list, removes the item at the given index, then emits the updated state.
- 13. dispose closes the StreamController to prevent memory leaks.
- 14. In main, we create a store and subscribe to its stream, printing every state change.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Flutter State Management Introduction (flutter.dev)
- Dart Streams (dart.dev)
- Flutter State Management Options (flutter.dev)