One state instead of many flags
A traffic light doesn’t have three switches that could all be on at once. It’s in exactly one state, red, yellow or green, and it knows what comes next from each one. Nobody has to check “is red on, and green off, and…?”.
The State pattern brings that to code. Instead of several flags, an object has one current state, and the rules for “what happens when the user presses Play” are written per state.
A finite state machine is states × events → states (plus side effects). Making it explicit gives three things flags can’t: impossible combinations become unrepresentable, every rule lives in one place where it can be reviewed against a diagram, and unexpected (state, event) pairs become a decision you make instead of a bug you discover. The implementation can be an enum with a transition function (most code) or GoF state objects (when per-state behavior is large); both are this pattern.
Before: Flags, checked differently in every handler
final class Player {
var isLoading = false
var isPlaying = false
var isPaused = false
var error: Error?
func playTapped() {
if !isPlaying { isLoading = true; load() } // paused? ended? error? who knows
}
func pauseTapped() {
isPaused = true // even if nothing is playing
}
}class Player {
var isLoading = false
var isPlaying = false
var isPaused = false
var error: Throwable? = null
fun playTapped() {
if (!isPlaying) { isLoading = true; load() } // paused? ended? error? who knows
}
fun pauseTapped() {
isPaused = true // even if nothing is playing
}
}class Player {
boolean isLoading, isPlaying, isPaused;
Throwable error;
void playTapped() {
if (!isPlaying) { isLoading = true; load(); } // paused? ended? error? who knows
}
void pauseTapped() {
isPaused = true; // even if nothing is playing
}
}class Player {
isLoading = false;
isPlaying = false;
isPaused = false;
error: Error | undefined;
playTapped(): void {
if (!this.isPlaying) { this.isLoading = true; this.load(); } // paused? ended? error? who knows
}
pauseTapped(): void {
this.isPaused = true; // even if nothing is playing
}
}After: One state, one transition function
enum PlayerState: Equatable { case idle, loading, playing, paused, failed(String) }
enum PlayerEvent { case playTapped, pauseTapped, loaded, loadFailed(String), ended }
func next(_ state: PlayerState, _ event: PlayerEvent) -> PlayerState {
switch (state, event) {
case (.idle, .playTapped), (.failed, .playTapped): .loading
case (.loading, .loaded): .playing
case (.loading, .loadFailed(let message)): .failed(message)
case (.playing, .pauseTapped): .paused
case (.paused, .playTapped): .playing
case (.playing, .ended): .idle
default: state // everything else: ignore
}
}sealed interface PlayerState {
data object Idle : PlayerState
data object Loading : PlayerState
data object Playing : PlayerState
data object Paused : PlayerState
data class Failed(val message: String) : PlayerState
}
sealed interface PlayerEvent {
data object PlayTapped : PlayerEvent
data object PauseTapped : PlayerEvent
data object Loaded : PlayerEvent
data class LoadFailed(val message: String) : PlayerEvent
data object Ended : PlayerEvent
}
fun next(state: PlayerState, event: PlayerEvent): PlayerState = when {
(state is PlayerState.Idle || state is PlayerState.Failed) && event is PlayerEvent.PlayTapped -> PlayerState.Loading
state is PlayerState.Loading && event is PlayerEvent.Loaded -> PlayerState.Playing
state is PlayerState.Loading && event is PlayerEvent.LoadFailed -> PlayerState.Failed(event.message)
state is PlayerState.Playing && event is PlayerEvent.PauseTapped -> PlayerState.Paused
state is PlayerState.Paused && event is PlayerEvent.PlayTapped -> PlayerState.Playing
state is PlayerState.Playing && event is PlayerEvent.Ended -> PlayerState.Idle
else -> state // everything else: ignore
}sealed interface PlayerState {
record Idle() implements PlayerState {}
record Loading() implements PlayerState {}
record Playing() implements PlayerState {}
record Paused() implements PlayerState {}
record Failed(String message) implements PlayerState {}
}
sealed interface PlayerEvent {
record PlayTapped() implements PlayerEvent {}
record PauseTapped() implements PlayerEvent {}
record Loaded() implements PlayerEvent {}
record LoadFailed(String message) implements PlayerEvent {}
record Ended() implements PlayerEvent {}
}
static PlayerState next(PlayerState state, PlayerEvent event) {
return switch (state) {
case PlayerState.Idle s when event instanceof PlayerEvent.PlayTapped -> new PlayerState.Loading();
case PlayerState.Failed s when event instanceof PlayerEvent.PlayTapped -> new PlayerState.Loading();
case PlayerState.Loading s when event instanceof PlayerEvent.Loaded -> new PlayerState.Playing();
case PlayerState.Loading s when event instanceof PlayerEvent.LoadFailed f -> new PlayerState.Failed(f.message());
case PlayerState.Playing s when event instanceof PlayerEvent.PauseTapped -> new PlayerState.Paused();
case PlayerState.Paused s when event instanceof PlayerEvent.PlayTapped -> new PlayerState.Playing();
case PlayerState.Playing s when event instanceof PlayerEvent.Ended -> new PlayerState.Idle();
default -> state; // everything else: ignore
};
}type PlayerState =
| { kind: "idle" } | { kind: "loading" } | { kind: "playing" } | { kind: "paused" }
| { kind: "failed"; message: string };
type PlayerEvent =
| { type: "playTapped" } | { type: "pauseTapped" } | { type: "loaded" }
| { type: "loadFailed"; message: string } | { type: "ended" };
function next(state: PlayerState, event: PlayerEvent): PlayerState {
switch (state.kind) {
case "idle":
case "failed":
return event.type === "playTapped" ? { kind: "loading" } : state;
case "loading":
if (event.type === "loaded") return { kind: "playing" };
if (event.type === "loadFailed") return { kind: "failed", message: event.message };
return state;
case "playing":
if (event.type === "pauseTapped") return { kind: "paused" };
if (event.type === "ended") return { kind: "idle" };
return state;
case "paused":
return event.type === "playTapped" ? { kind: "playing" } : state;
}
}Three ideas:
- State: where the player is right now. Exactly one at a time, so “paused and loading” can’t exist.
- Events: things that happen to it: a tap, a download finishing, the video ending.
- The transition function
next(state, event): the one place that says what each event does in each state. Pressing Pause while loading? There’s no rule for it, so nothing happens, deliberately.
Drawing it helps: circles for states, arrows labeled with events. The function is that drawing in code.
Tap the line with the bug.
final class Player {
private(set) var isLoading = false
private(set) var isPlaying = false
func playTapped() {
if !isPlaying {
isLoading = true
startDownload()
}
}
}class Player {
private var isLoading = false
private var isPlaying = false
fun playTapped() {
if (!isPlaying) {
isLoading = true
startDownload()
}
}
}class Player {
private boolean isLoading = false;
private boolean isPlaying = false;
void playTapped() {
if (!isPlaying) {
isLoading = true;
startDownload();
}
}
}class Player {
private isLoading = false;
private isPlaying = false;
playTapped(): void {
if (!this.isPlaying) {
this.isLoading = true;
this.startDownload();
}
}
}Hint: On a slow network the user taps Play, nothing seems to happen for two seconds, so they tap again. What's isPlaying during those two seconds?
While the video is loading, isPlaying is still false, so the second tap passes the check and starts another download. Two downloads race, both “finish”, and the player may start twice or show the wrong one. The check asks “is it playing?” when the real question is “what state is it in?”, and the flags allow a combination (isLoading while a second load starts) nobody designed.
With one state, the rule is explicit: in loading, a play tap does nothing:
func playTapped() {
switch state {
case .idle, .failed: state = .loading; startDownload()
case .paused: state = .playing; resume()
case .loading, .playing: break // already on its way
}
}fun playTapped() {
when (state) {
PlayerState.Idle, is PlayerState.Failed -> { state = PlayerState.Loading; startDownload() }
PlayerState.Paused -> { state = PlayerState.Playing; resume() }
PlayerState.Loading, PlayerState.Playing -> Unit // already on its way
}
}void playTapped() {
switch (state) {
case PlayerState.Idle s -> { state = new PlayerState.Loading(); startDownload(); }
case PlayerState.Failed s -> { state = new PlayerState.Loading(); startDownload(); }
case PlayerState.Paused s -> { state = new PlayerState.Playing(); resume(); }
case PlayerState.Loading s -> { } // already on its way
case PlayerState.Playing s -> { }
}
}playTapped(): void {
switch (this.state.kind) {
case "idle":
case "failed": this.state = { kind: "loading" }; this.startDownload(); break;
case "paused": this.state = { kind: "playing" }; this.resume(); break;
case "loading":
case "playing": break; // already on its way
}
}Practice: draw it first
- List the states the user would recognize: idle, loading, playing, paused, failed.
- List the events: taps, results arriving, the video ending, the network dropping.
- Draw arrows for each event that changes the state, and label them.
- Look at every state and ask, for every event: what should happen here? “Nothing” is a valid answer, but it should be a decision.
- Write the transition function from the drawing, and one test per arrow.
Transition functions vs scattered handlers
A pure next(state, event) (or reduce) has three practical advantages over handlers that mutate state: it’s trivially table-testable ((state, event, expected) rows), it separates decisions from effects (return the next state plus a list of effects like “start download”, executed by the caller), and it composes with unidirectional architectures (MVI, Redux, TCA) where every change flows through it. For large machines, statechart libraries (XState, KStateMachine, Spring Statemachine) add hierarchy, guards and history, at the cost of a DSL; most app screens don’t need them.
- Flags → one state value; inputs → events; rules → one transition function
(state, event) → state(+ effects). Unexpected pairs are explicit no-ops or errors. - Return effects instead of performing them inside the function, so it stays pure and table-testable.
- Statechart libraries for hierarchical/guarded machines; an enum + switch for most screens.
Check this part
Why replace isLoading, isPlaying and isPaused with one state value?
The user taps Play while the video is loading. In the state machine, what should happen?
Why return effects from the transition function instead of performing them inside it?