Skip to content
Commitwise
Menu
Design Patterns

State

Replace tangled boolean flags with one explicit state and a transition function, move per-state behavior into state objects when it grows, and handle async results, timeouts and persistence without invalid transitions, in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A video player has isLoading, isPlaying, isPaused, hasEnded and error. Every button handler checks a different subset, and every bug report describes a combination nobody imagined: paused and loading, ended and playing, an error shown over a video that’s running fine. Five booleans allow 32 combinations, and the player really has five situations. The State pattern makes the situation itself a value, and lets it decide what each button means.

New to some of these? Open what you need, or skip ahead.

Basics:What are enums and sealed types?

An enum lists a fixed set of values: enum Size { small, medium, large }. Swift enums can carry data with each case (case failed(Error)), and Kotlin sealed classes, Java sealed interfaces and TypeScript unions do the same: “exactly one of these, each with its own data”. A switch/when over them without a default is checked by the compiler. The Enums and sealed hierarchies tutorial covers them in detail.

Basics:What is a design pattern?

A design pattern is a named, reusable solution to a problem that keeps coming up in software design, like “swap one algorithm for another” or “notify many objects of a change”. It’s not a library you install; it’s a way of arranging classes and interfaces that experienced developers recognize.

Knowing the names helps in two ways: you recognize the pattern in code you read (Android, iOS and Spring use many of them), and you can discuss designs quickly in reviews and interviews (“let’s use a Strategy here”).

Part Not started

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.

Spot the bugTwo downloads of the same video

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();
    }
  }
}

Practice: draw it first

  1. List the states the user would recognize: idle, loading, playing, paused, failed.
  2. List the events: taps, results arriving, the video ending, the network dropping.
  3. Draw arrows for each event that changes the state, and label them.
  4. Look at every state and ask, for every event: what should happen here? “Nothing” is a valid answer, but it should be a decision.
  5. 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

  1. Why replace isLoading, isPlaying and isPaused with one state value?

  2. The user taps Play while the video is loading. In the state machine, what should happen?

  3. Why return effects from the transition function instead of performing them inside it?

Several booleans describing one thing allow impossible combinations and scatter the rules. Model the situation as one state value, the inputs as events, and put every rule in one transition function: (state, event) → next state.

Part Not started

States as objects

A document in a publishing tool behaves differently depending on its stage. A draft can be edited and submitted. A document in review can be approved or sent back, but not edited. A published one can only be archived. You could put an if stage == … in every method, or you could give each stage its own object that knows what it allows.

In the classic State pattern, each state is a class implementing the same interface (one method per event), and the document simply asks its current state object what to do.

GoF State: the context delegates behavior to a state object; each concrete state implements every event, returns or installs the next state, and encapsulates its own data and side effects. It shines when per-state behavior is substantial (different validation, different UI, different timers), and costs a class per state plus transitions spread across classes. Enum-plus-switch keeps transitions in one place and shines when states are mostly data. Choosing between them is an expression-problem trade-off: new states vs new events.

protocol DocumentState {
    var name: String { get }
    func edit() throws -> any DocumentState
    func submit() throws -> any DocumentState
    func approve() throws -> any DocumentState
    func reject() throws -> any DocumentState
}

struct NotAllowed: Error { let action: String; let state: String }

struct Draft: DocumentState {
    let name = "Draft"
    func edit() -> any DocumentState { self }
    func submit() -> any DocumentState { InReview() }
    func approve() throws -> any DocumentState { throw NotAllowed(action: "approve", state: name) }
    func reject() throws -> any DocumentState { throw NotAllowed(action: "reject", state: name) }
}

struct InReview: DocumentState {
    let name = "In review"
    func edit() throws -> any DocumentState { throw NotAllowed(action: "edit", state: name) }
    func submit() throws -> any DocumentState { throw NotAllowed(action: "submit", state: name) }
    func approve() -> any DocumentState { Published() }
    func reject() -> any DocumentState { Draft() }
}

struct Published: DocumentState {
    let name = "Published"
    func edit() throws -> any DocumentState { throw NotAllowed(action: "edit", state: name) }
    func submit() throws -> any DocumentState { throw NotAllowed(action: "submit", state: name) }
    func approve() throws -> any DocumentState { throw NotAllowed(action: "approve", state: name) }
    func reject() throws -> any DocumentState { throw NotAllowed(action: "reject", state: name) }
}

final class Document {
    private(set) var state: any DocumentState = Draft()
    func submit() throws { state = try state.submit() }       // the state decides
    func approve() throws { state = try state.approve() }
}
sealed class DocumentState(val name: String) {
    open fun edit(): DocumentState = notAllowed("edit")
    open fun submit(): DocumentState = notAllowed("submit")
    open fun approve(): DocumentState = notAllowed("approve")
    open fun reject(): DocumentState = notAllowed("reject")
    protected fun notAllowed(action: String): Nothing =
        throw IllegalStateException("Can't $action a document that is $name")

    data object Draft : DocumentState("Draft") {
        override fun edit() = this
        override fun submit() = InReview
    }
    data object InReview : DocumentState("In review") {
        override fun approve() = Published
        override fun reject() = Draft
    }
    data object Published : DocumentState("Published")
}

class Document {
    var state: DocumentState = DocumentState.Draft
        private set
    fun submit() { state = state.submit() }                    // the state decides
    fun approve() { state = state.approve() }
}
abstract sealed class DocumentState permits Draft, InReview, Published {
    abstract String name();
    DocumentState edit() { throw notAllowed("edit"); }
    DocumentState submit() { throw notAllowed("submit"); }
    DocumentState approve() { throw notAllowed("approve"); }
    DocumentState reject() { throw notAllowed("reject"); }
    private IllegalStateException notAllowed(String action) {
        return new IllegalStateException("Can't " + action + " a document that is " + name());
    }
}

final class Draft extends DocumentState {
    String name() { return "Draft"; }
    @Override DocumentState edit() { return this; }
    @Override DocumentState submit() { return new InReview(); }
}

final class InReview extends DocumentState {
    String name() { return "In review"; }
    @Override DocumentState approve() { return new Published(); }
    @Override DocumentState reject() { return new Draft(); }
}

final class Published extends DocumentState {
    String name() { return "Published"; }
}

final class Document {
    private DocumentState state = new Draft();
    DocumentState state() { return state; }
    void submit() { state = state.submit(); }                  // the state decides
    void approve() { state = state.approve(); }
}
abstract class DocumentState {
  abstract readonly name: string;
  edit(): DocumentState { return this.notAllowed("edit"); }
  submit(): DocumentState { return this.notAllowed("submit"); }
  approve(): DocumentState { return this.notAllowed("approve"); }
  reject(): DocumentState { return this.notAllowed("reject"); }
  protected notAllowed(action: string): never {
    throw new Error(`Can't ${action} a document that is ${this.name}`);
  }
}

class Draft extends DocumentState {
  readonly name = "Draft";
  override edit(): DocumentState { return this; }
  override submit(): DocumentState { return new InReview(); }
}

class InReview extends DocumentState {
  readonly name = "In review";
  override approve(): DocumentState { return new Published(); }
  override reject(): DocumentState { return new Draft(); }
}

class Published extends DocumentState {
  readonly name = "Published";
}

class DocumentModel {
  private state: DocumentState = new Draft();
  get stateName(): string { return this.state.name; }
  submit(): void { this.state = this.state.submit(); }        // the state decides
  approve(): void { this.state = this.state.approve(); }
}

Document doesn’t contain a single if about stages. It forwards each action to its current state object, which either returns the next state or refuses. Adding a new stage (“scheduled for publishing”) means one new class; the rules for each stage are all in one place.

State vs Strategy: same shape (a context delegating to an interchangeable object), different control. With Strategy, the client picks the object and it rarely changes; with State, the objects move the context between them as events arrive. If your “strategies” start returning the next strategy, you’ve built a state machine.

Predict the output

A new document goes through submit, reject, submit, approve. Then someone tries edit. What happens?

Exerciseeasy

A turnstile

A subway turnstile is locked or unlocked. Inserting a coin unlocks it; pushing through an unlocked turnstile locks it again. Pushing a locked one does nothing (and should count an “attempt to pass without paying”). Inserting a coin when it’s already unlocked should refund the coin, not keep it. Write the state, the events and the transition, returning the next state plus what the machine should do (unlock, lock, refund, alarmCount).

›

Return a pair: the next state and an action (or a list of actions). The caller performs the actions; the function only decides.

SolutionTry it yourself first
enum TurnstileState { case locked, unlocked }
enum TurnstileEvent { case coin, push }
enum TurnstileAction { case unlock, lock, refund, countAttempt }

func next(_ state: TurnstileState, _ event: TurnstileEvent) -> (TurnstileState, TurnstileAction) {
    switch (state, event) {
    case (.locked, .coin):   (.unlocked, .unlock)
    case (.locked, .push):   (.locked, .countAttempt)
    case (.unlocked, .coin): (.unlocked, .refund)
    case (.unlocked, .push): (.locked, .lock)
    }
}
enum class TurnstileState { LOCKED, UNLOCKED }
enum class TurnstileEvent { COIN, PUSH }
enum class TurnstileAction { UNLOCK, LOCK, REFUND, COUNT_ATTEMPT }

fun next(state: TurnstileState, event: TurnstileEvent): Pair<TurnstileState, TurnstileAction> =
    when (state) {
        TurnstileState.LOCKED -> when (event) {
            TurnstileEvent.COIN -> TurnstileState.UNLOCKED to TurnstileAction.UNLOCK
            TurnstileEvent.PUSH -> TurnstileState.LOCKED to TurnstileAction.COUNT_ATTEMPT
        }
        TurnstileState.UNLOCKED -> when (event) {
            TurnstileEvent.COIN -> TurnstileState.UNLOCKED to TurnstileAction.REFUND
            TurnstileEvent.PUSH -> TurnstileState.LOCKED to TurnstileAction.LOCK
        }
    }
enum TurnstileState { LOCKED, UNLOCKED }
enum TurnstileEvent { COIN, PUSH }
enum TurnstileAction { UNLOCK, LOCK, REFUND, COUNT_ATTEMPT }
record Transition(TurnstileState next, TurnstileAction action) {}

static Transition next(TurnstileState state, TurnstileEvent event) {
    return switch (state) {
        case LOCKED -> switch (event) {
            case COIN -> new Transition(TurnstileState.UNLOCKED, TurnstileAction.UNLOCK);
            case PUSH -> new Transition(TurnstileState.LOCKED, TurnstileAction.COUNT_ATTEMPT);
        };
        case UNLOCKED -> switch (event) {
            case COIN -> new Transition(TurnstileState.UNLOCKED, TurnstileAction.REFUND);
            case PUSH -> new Transition(TurnstileState.LOCKED, TurnstileAction.LOCK);
        };
    };
}
type TurnstileState = "locked" | "unlocked";
type TurnstileEvent = "coin" | "push";
type TurnstileAction = "unlock" | "lock" | "refund" | "countAttempt";

function next(state: TurnstileState, event: TurnstileEvent): [TurnstileState, TurnstileAction] {
  if (state === "locked") return event === "coin" ? ["unlocked", "unlock"] : ["locked", "countAttempt"];
  return event === "coin" ? ["unlocked", "refund"] : ["locked", "lock"];
}

Two states × two events = four rows, and all four are written out, including the two “unusual” ones (pushing while locked, paying twice). A table test with exactly those four rows proves the machine. That completeness is what flag-based code never has.

  • GoF State: context delegates to a state object; each state implements all events (defaults refuse) and returns/installs the next state. Good for heavy per-state behavior; enum + switch for data-like states with central transitions.
  • State vs Strategy: the objects drive transitions (State) vs the client chooses (Strategy).
  • Sealed classes with default “not allowed” methods (Kotlin, Java) give compact state objects; Swift uses protocols with per-state structs.

Check this part

  1. In the classic State pattern, who decides what an action does?

  2. How is "published documents can't be edited" expressed with state objects?

  3. Your strategies start returning the next strategy to use. What do you have now?

In the classic State pattern, each state is an object that handles the events itself and returns (or sets) the next state. It fits when each state has a lot of behavior; an enum with a switch fits when states are mostly data. State changes itself; Strategy is chosen from outside.

Part Not started

State machines in real apps

Real events don’t arrive politely one at a time. A download finishes after the user already pressed Cancel. A payment confirmation arrives after the app was restarted. A timer fires for a screen that’s gone. Each of these is an event arriving in a state that didn’t expect it, and the transition function is exactly the place to decide what happens.

The bugs in production state machines are almost all about time: results of work started in an old state arriving in a new one (stale completions), missing events (timeouts that never fire, so a state is stuck), duplicates (retries, at-least-once delivery), and state lost across process death or deploys. The defenses are correlation (request/attempt ids carried by result events), explicit timeout events, idempotent transitions, and persisted state with a version for optimistic concurrency.

enum SearchState: Equatable {
    case idle
    case loading(requestID: UUID, query: String)
    case results(query: String, items: [String])
}

enum SearchEvent {
    case queryChanged(String)
    case resultsArrived(requestID: UUID, items: [String])
}

func next(_ state: SearchState, _ event: SearchEvent) -> SearchState {
    switch (state, event) {
    case (_, .queryChanged(let q)):
        return .loading(requestID: UUID(), query: q)
    case (.loading(let current, let q), .resultsArrived(let id, let items)) where id == current:
        return .results(query: q, items: items)
    default:
        return state                                        // a stale result: ignore it
    }
}
sealed interface SearchState {
    data object Idle : SearchState
    data class Loading(val requestId: Long, val query: String) : SearchState
    data class Results(val query: String, val items: List<String>) : SearchState
}

sealed interface SearchEvent {
    data class QueryChanged(val query: String, val requestId: Long) : SearchEvent
    data class ResultsArrived(val requestId: Long, val items: List<String>) : SearchEvent
}

fun next(state: SearchState, event: SearchEvent): SearchState = when (event) {
    is SearchEvent.QueryChanged -> SearchState.Loading(event.requestId, event.query)
    is SearchEvent.ResultsArrived ->
        if (state is SearchState.Loading && state.requestId == event.requestId)
            SearchState.Results(state.query, event.items)
        else state                                            // a stale result: ignore it
}
sealed interface SearchState {
    record Idle() implements SearchState {}
    record Loading(long requestId, String query) implements SearchState {}
    record Results(String query, List<String> items) implements SearchState {}
}

sealed interface SearchEvent {
    record QueryChanged(String query, long requestId) implements SearchEvent {}
    record ResultsArrived(long requestId, List<String> items) implements SearchEvent {}
}

static SearchState next(SearchState state, SearchEvent event) {
    return switch (event) {
        case SearchEvent.QueryChanged q -> new SearchState.Loading(q.requestId(), q.query());
        case SearchEvent.ResultsArrived r -> (state instanceof SearchState.Loading l && l.requestId() == r.requestId())
                ? new SearchState.Results(l.query(), r.items())
                : state;                                      // a stale result: ignore it
    };
}
type SearchState =
  | { kind: "idle" }
  | { kind: "loading"; requestId: number; query: string }
  | { kind: "results"; query: string; items: string[] };

type SearchEvent =
  | { type: "queryChanged"; query: string; requestId: number }
  | { type: "resultsArrived"; requestId: number; items: string[] };

function next(state: SearchState, event: SearchEvent): SearchState {
  switch (event.type) {
    case "queryChanged":
      return { kind: "loading", requestId: event.requestId, query: event.query };
    case "resultsArrived":
      return state.kind === "loading" && state.requestId === event.requestId
        ? { kind: "results", query: state.query, items: event.items }
        : state;                                          // a stale result: ignore it
  }
}

The user types “ca”, then “cat”. Two searches start. If the “ca” results arrive after the “cat” ones, a naive screen shows results for “ca” under the search box that says “cat”. Here, every search has an id, the loading state remembers which id it’s waiting for, and results with any other id are ignored.

Production puzzles

Spot the bugCancelled uploads that complete anyway

Tap the line with the bug.

func handle(_ event: UploadEvent) {
    switch event {
    case .cancelTapped:
        state = .cancelled
    case .finished(let url):
        state = .done(url: url)
        analytics.track("upload_completed")
    }
}
fun handle(event: UploadEvent) {
    when (event) {
        UploadEvent.CancelTapped -> state = UploadState.Cancelled
        is UploadEvent.Finished -> {
            state = UploadState.Done(event.url)
            analytics.track("upload_completed")
        }
    }
}
void handle(UploadEvent event) {
    switch (event) {
        case UploadEvent.CancelTapped c -> state = new UploadState.Cancelled();
        case UploadEvent.Finished f -> {
            state = new UploadState.Done(f.url());
            analytics.track("upload_completed");
        }
    }
}
handle(event: UploadEvent): void {
  switch (event.type) {
    case "cancelTapped":
      this.state = { kind: "cancelled" };
      break;
    case "finished":
      this.state = { kind: "done", url: event.url };
      analytics.track("upload_completed");
      break;
  }
}

Exercisemedium

A media player that survives the real world

Design the state machine for a podcast player: play, pause, seek, buffering when the network is slow, losing the network entirely, an incoming phone call (audio interruption), the app going to the background, the episode ending (auto-play the next), and the app being killed and relaunched. Give the states, the events, the important transitions, and how the state survives a relaunch.

›

Some situations are temporary interruptions of “playing” (buffering, a call). Remember why you stopped so you know what to resume to. Timeouts are events too.

SolutionTry it yourself first
  • States: idle, loading(episode), playing(episode, position), buffering(episode, position, resumeTo: playing), paused(episode, position, reason: user | interruption | networkLost), ended(episode), failed(episode, error).
  • Events: play, pause, seek(t), bufferEmpty, bufferReady, networkLost, networkBack, interruptionBegan, interruptionEnded(shouldResume), didEnd, loadFailed(e), bufferTimeout, restored(snapshot).
  • Key transitions: playing + bufferEmpty → buffering (start a timeout; bufferTimeout → paused(networkLost)); playing + interruptionBegan → paused(interruption); paused(interruption) + interruptionEnded(shouldResume: true) → playing, but paused(user) ignores it (the user paused on purpose); paused(networkLost) + networkBack → loading at the saved position; playing + didEnd → ended → auto-play enqueues loading(nextEpisode); seek in playing/paused keeps the state and updates position; pause in buffering → paused(user).
  • Background: not a state change for audio (it keeps playing under the system’s background audio mode); it is an event for UI-only work (stop progress timers).
  • Persistence: save a snapshot (episodeId, position, wasPlaying, version) on every transition out of playing and periodically while playing; on relaunch, restored(snapshot) → paused(user) at that position (never auto-resume audio after a kill), and resolve conflicts with listening position synced from other devices by version/timestamp.
  • Tests: a table of (state, event, expected state, expected effects) covering every interruption pair, especially “user paused, then a call ended”.
  • Every async result is an event checked against the current state; correlate with request/attempt ids; cancelling must also cancel (or compensate) the work.
  • Timeouts are explicit events (a state with no exit is a stuck screen); transitions should be idempotent for duplicate events.
  • Remember interruption reasons (paused(user) vs paused(interruption)) to resume correctly.
  • Persist snapshots with a version; restore conservatively; table-test (state, event) → (state, effects).

Check this part

  1. Results for "ca" arrive after the user already typed "cat". How does the state machine avoid showing them?

  2. An upload finishes after the user cancelled it. What should the state machine do?

  3. Why store a reason in paused(user) vs paused(interruption)?

Async results, timeouts and app restarts are events too, and they can arrive in a state that no longer expects them. Check that the result belongs to the current state (a request id), treat timeouts as events, persist the state with a version, and test every transition from a table.

Key takeaways

  • Replace several flags with one state value: impossible combinations disappear.
  • Write every rule in one transition function, (state, event) → next state, and decide what happens for every pair.
  • In the classic State pattern, each state is an object that handles events and returns the next state.
  • Async results, cancellations and timeouts are events too: check them against the current state.
  • Explicit state machines make rules reviewable and table-testable; return effects instead of performing them.
  • Choose state objects for heavy per-state behavior, enum + switch for data-like states.
  • Correlate async results, model timeouts and interruption reasons explicitly.
  • Persist versioned snapshots and restore conservatively.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.