jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Design Patterns in TypeScript · Part 9 — State & State Machines

Make impossible states impossible: the State pattern, modeling UI/lifecycle with a finite state machine, type-safe transitions via discriminated unions, and why this kills "loading && error" bugs.

Part 9 of 10 in the Design Patterns in TypeScript series {Phần 9/10 trong series Design Patterns in TypeScript}. Previous {Trước}: Part 8 — Command & Memento · Next {Tiếp}: Part 10 — Proxy & Dependency Injection.

This is Part 9 of a 10-part series on the design patterns every senior web engineer should have in their hands — explained with runnable TypeScript, real frontend/back-of-front use cases, and exercises at the end of each part {Đây là Phần 9 của series 10 bài về các design pattern mà mọi senior web nên nắm — giải thích bằng TypeScript chạy được, use case web thực tế, và bài tập ở cuối mỗi phần}. In Part 8 — Command & Memento you turned actions into objects you can queue and undo {Ở Phần 8 — Command & Memento bạn biến hành động thành object có thể xếp hàng và undo}; here we tame state — what the UI or service is right now, and which changes are legal {ở đây ta thuần hóa trạng thái — UI hoặc service đang ở đâu, và thay đổi nào là hợp lệ}.

The smell is familiar: isLoading, isError, data, and error as separate booleans {Mùi quen thuộc: isLoading, isError, data, và error là các boolean rời}. Nothing stops isLoading === true && isError === true with data === undefined — a screen that shows a spinner and an error banner at once {Không gì ngăn isLoading === true && isError === true khi data === undefined — màn hình vừa spinner vừa banner lỗi}. State and finite state machines (FSMs) replace that soup with one value that can only be in one place at a time {Statemáy trạng thái hữu hạn (FSM) thay mớ đó bằng một giá trị chỉ có thể ở một chỗ mỗi lúc}.


The intent {Ý đồ}

The State pattern lets an object change behavior when its internal state changes — often by delegating to a state object instead of a giant switch {State cho object đổi hành vi khi trạng thái nội bộ đổi — thường bằng ủy quyền cho object state thay vì switch khổng lồ}. A finite state machine goes further: you declare a fixed set of states and only the transitions you allow between them {FSM đi xa hơn: khai báo tập trạng thái cố địnhchỉ các chuyển tiếp bạn cho phép}. The compiler then helps you handle every state and reject nonsense {Trình biên dịch giúp bạn xử lý mọi state và từ chối vô nghĩa}.

idle FETCH loading RESOLVE success REJECT error only declared transitions are allowed (type-safe)
One state at a time; events move you along declared edges — behavior follows the current node

State pattern = behavior varies with state {State pattern = hành vi thay theo state}. FSM = explicit nodes and edges {FSM = nút và cạnh rõ ràng}. In TypeScript, the idiomatic form is usually a discriminated union plus a pure transition(state, event) — not a forest of classes — unless you need polymorphic objects for a long-lived domain entity {Trong TypeScript, dạng idiomatic thường là discriminated union cộng transition(state, event) thuần — không phải rừng class — trừ khi bạn cần object đa hình cho entity sống lâu}.


Make illegal states unrepresentable {Làm state bất hợp pháp không biểu diễn được}

Replace parallel flags with a tagged union {Thay các cờ song song bằng union có tag}:

// ❌ Boolean soup — many combinations are meaningless
type FetchFlags = {
  isLoading: boolean;
  isError: boolean;
  data: User[] | undefined;
  error: string | undefined;
};

// ✅ One field tells you everything; fields exist only where valid
type FetchState<T, E = string> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: E };

type User = { id: string; name: string };

function renderUsers(state: FetchState<User[]>): string {
  switch (state.status) {
    case 'idle':
      return 'Press fetch to load users.';
    case 'loading':
      return 'Loading…';
    case 'success':
      // `data` exists only here — no optional chaining guesswork
      return state.data.map((u) => u.name).join(', ');
    case 'error':
      return `Error: ${state.error}`;
    default: {
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}

data is not optional on the whole object — it exists only in success {data không optional trên cả object — nó chỉ có trong success}. You cannot read state.data in loading without the compiler stopping you {Bạn không đọc state.data trong loading mà compiler không chặn}. That is make illegal states unrepresentable in practice {Đó là làm state bất hất pháp không biểu diễn được trong thực tế}.


A typed transition function {Hàm chuyển tiếp có kiểu}

Model events separately from states {Mô hình event tách khỏi state}. A pure transition returns the next state; illegal pairs return the same state or a dedicated invalid path you document {transition thuần trả state kế tiếp; cặp bất hợp pháp giữ nguyên state hoặc nhánh invalid bạn ghi rõ}:

type FetchState<T, E = string> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: E };

type FetchEvent<T, E = string> =
  | { type: 'FETCH' }
  | { type: 'SUCCESS'; data: T }
  | { type: 'FAIL'; error: E }
  | { type: 'RETRY' };

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function transition<T, E>(
  state: FetchState<T, E>,
  event: FetchEvent<T, E>,
): FetchState<T, E> {
  switch (state.status) {
    case 'idle':
      if (event.type === 'FETCH') return { status: 'loading' };
      return state;
    case 'loading':
      if (event.type === 'SUCCESS') return { status: 'success', data: event.data };
      if (event.type === 'FAIL') return { status: 'error', error: event.error };
      return state; // FETCH while loading — ignore (or log in the caller)
    case 'success':
      if (event.type === 'FETCH') return { status: 'loading' };
      return state;
    case 'error':
      if (event.type === 'RETRY' || event.type === 'FETCH') return { status: 'loading' };
      return state;
    default:
      return assertNever(state);
  }
}

// Happy path
let s: FetchState<User[]> = { status: 'idle' };
s = transition(s, { type: 'FETCH' });
s = transition(s, { type: 'SUCCESS', data: [{ id: '1', name: 'Ada' }] });
console.log(s.status); // 'success'

Exhaustiveness on state.status and, if you nest, on event.type means a new variant becomes a compile error until you handle it {Exhaustiveness trên state.status và, nếu lồng, trên event.type nghĩa là variant mới thành lỗi biên dịch cho tới khi bạn xử lý}. Keep side effects (fetch, analytics, toasts) in the layer that dispatches events, not inside transition {Giữ side effect (fetch, analytics, toast) ở lớp dispatch event, không trong transition} — the transition stays easy to unit test {transition dễ unit test}.

A checkout wizard uses the same shape {Wizard checkout cùng hình dạng}:

type CheckoutState =
  | { step: 'cart' }
  | { step: 'shipping'; addressId: string }
  | { step: 'payment'; addressId: string }
  | { step: 'done'; orderId: string };

type CheckoutEvent =
  | { type: 'SELECT_ADDRESS'; addressId: string }
  | { type: 'CONFIRM_SHIPPING' }
  | { type: 'PAY'; orderId: string }
  | { type: 'BACK' };

function checkoutTransition(
  state: CheckoutState,
  event: CheckoutEvent,
): CheckoutState {
  switch (state.step) {
    case 'cart':
      if (event.type === 'SELECT_ADDRESS') {
        return { step: 'shipping', addressId: event.addressId };
      }
      return state;
    case 'shipping':
      if (event.type === 'CONFIRM_SHIPPING') {
        return { step: 'payment', addressId: state.addressId };
      }
      if (event.type === 'BACK') return { step: 'cart' };
      return state;
    case 'payment':
      if (event.type === 'PAY') {
        return { step: 'done', orderId: event.orderId };
      }
      if (event.type === 'BACK') {
        return { step: 'shipping', addressId: state.addressId };
      }
      return state;
    case 'done':
      return state; // terminal — no transitions out
    default:
      return assertNever(state);
  }
}

You cannot jump from cart to done without going through the declared edges {Bạn không nhảy từ cart sang done mà không đi qua cạnh đã khai báo}.


The classic State pattern (objects) {State pattern kinh điển (object)}

The Gang of Four picture: a Context holds a State interface; each concrete state class implements handle() differently {Ảnh GoF: Context giữ interface State; mỗi class state cụ thể implement handle() khác nhau}:

interface MediaPlayerState {
  readonly name: string;
  play(player: MediaPlayer): void;
  pause(player: MediaPlayer): void;
}

class Playing implements MediaPlayerState {
  readonly name = 'playing';
  play() {
    /* already playing */
  }
  pause(player: MediaPlayer) {
    player.setState(new Paused());
  }
}

class Paused implements MediaPlayerState {
  readonly name = 'paused';
  play(player: MediaPlayer) {
    player.setState(new Playing());
  }
  pause() {
    /* already paused */
  }
}

class MediaPlayer {
  private state: MediaPlayerState = new Paused();
  setState(next: MediaPlayerState) {
    this.state = next;
  }
  play() {
    this.state.play(this);
  }
  pause() {
    this.state.pause(this);
  }
  label() {
    return this.state.name;
  }
}

This shines when behavior is chunky and object-oriented (many methods per state) {Hợp khi hành vi nặng và hướng object (nhiều method mỗi state)}. For most UI and async flows in TS, prefer the union + transition approach: serializable state (JSON, Redux, URL), exhaustive switch, and no new per keystroke {Với UI và flow async trong TS, ưu tiên union + transition: state serialize được (JSON, Redux, URL), switch exhaustive, không new mỗi lần gõ}. You can mix both: store { status: 'playing' } in state, delegate rendering to small functions per status {Trộn được: lưu { status: 'playing' } trong state, ủy quyền render cho hàm nhỏ theo status}.


When to reach for a library {Khi nào dùng thư viện}

Hand-rolled unions are enough for flat machines: fetch status, auth gate, simple wizard {Union tự viết đủ cho máy phẳng: trạng thái fetch, cổng auth, wizard đơn giản}. Reach for XState (or statecharts) when you need hierarchical states (checkout inside authenticated), parallel regions (upload + form validation), guards (canPay only if cart non-empty), or visual tooling for PMs and QA {Dùng XState khi cần state phân cấp, vùng song song, guard, hoặc công cụ trực quan cho PM/QA}. Libraries do not remove the design job — they encode the same graph with less boilerplate for complex graphs {Thư viện không bỏ việc thiết kế — chúng mã hóa cùng đồ thị với ít boilerplate hơn cho đồ thị phức tạp}. For a four-state fetch, a 40-line transition beats importing a runtime {Với fetch bốn state, transition 40 dòng thắng import runtime}.


Real web use cases {Use case web thực tế}

AreaStates (examples)Why FSM helps
Async dataidleloadingsuccess | errorNo loading && error; refetch is an explicit event
Form wizardstep1step2reviewsubmittedBack/next only where allowed
Auth / sessionanonymousauthenticatingauthenticated | expiredRoute guards key off one status
Media playerpausedplayingendedControls map 1:1 to transitions
ConnectionofflineconnectingonlineReconnect policy per edge, not random timers
Toggle / traffic lightredgreenyellowUI animation syncs to discrete states

The pattern pairs well with Part 8 — Command: commands are what you do; state is what you are after they run {Pattern ăn khớp Phần 8 — Command: command là việc bạn làm; state là việc bạn đang là sau khi chạy}. Part 10 will inject services that dispatch events without the UI knowing fetch details {Phần 10 sẽ inject service dispatch event mà UI không cần biết chi tiết fetch}.


Pitfalls {Cạm bẫy}

  • Keeping boolean flags alongside the union — you reintroduce impossible combos; delete the flags {Giữ boolean cạnh union — bạn tái tạo combo không thể; xóa cờ}.
  • Any-to-any transitions — if every event works in every state, you still have a blob; draw the graph first {Chuyển mọi-mọi — nếu mọi event chạy mọi state, vẫn là blob; vẽ đồ thị trước}.
  • Side effects inside transition — hard to test and replay; dispatch effects after a pure step {Side effect trong transition — khó test và replay; chạy effect sau bước thuần}.
  • State explosion — ten booleans become 2¹⁰ nodes; collapse dimensions (e.g. separate connection from fetch) or use hierarchical charts {Bùng nổ state — mười boolean thành 2¹⁰ nút; gộp chiều (tách connection khỏi fetch) hoặc dùng biểu đồ phân cấp}.
  • Forgetting terminal statesdone should not accept PAY; document sinks explicitly {Quên state kếtdone không nhận PAY; ghi rõ sink}.

Cheat sheet {Bảng tra nhanh}

// State = discriminated union
type S = { status: 'idle' } | { status: 'loading' } | { status: 'ok'; data: T };

// Event = separate union
type E = { type: 'GO' } | { type: 'DONE'; data: T };

// Pure transition + exhaustive never
function transition(state: S, event: E): S { /* switch both */ }

// Render: one switch on state.status — data only in 'ok'

// Effects: fetch(), navigate() AFTER transition in the dispatcher

Decision: UI/async, few states → union + transition; rich behavior per state, long-lived object → State classes; hierarchical/parallel → XState {Quyết định: UI/async, ít state → union + transition; hành vi dày, object sống lâu → class State; phân cấp/song song → XState}.


Bài tập / Exercises

1. Convert this boolean soup into a FetchState discriminated union and a render(state) function with an exhaustive switch {Chuyển mớ boolean này thành discriminated union FetchState và hàm render(state) với switch exhaustive}:

// Starting point — do not keep these flags
let isLoading = false;
let isError = false;
let data: string | undefined;
let error: string | undefined;
Solution {Lời giải}
type FetchState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: string };

function render(state: FetchState): string {
  switch (state.status) {
    case 'idle':
      return 'Idle';
    case 'loading':
      return 'Loading…';
    case 'success':
      return state.data;
    case 'error':
      return `Error: ${state.error}`;
    default: {
      const _never: never = state;
      return _never;
    }
  }
}

const ok: FetchState = { status: 'success', data: 'hello' };
console.log(render(ok)); // 'hello'

2. Implement transition(state, event) for the fetch machine (FETCH, SUCCESS, FAIL, RETRY) and use assertNever on the default branch of state {Cài transition(state, event) cho máy fetch (FETCH, SUCCESS, FAIL, RETRY) và dùng assertNever ở nhánh default của state}.

Solution {Lời giải}
type FetchState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: string };

type FetchEvent =
  | { type: 'FETCH' }
  | { type: 'SUCCESS'; data: string }
  | { type: 'FAIL'; error: string }
  | { type: 'RETRY' };

function assertNever(x: never): never {
  throw new Error(String(x));
}

function transition(state: FetchState, event: FetchEvent): FetchState {
  switch (state.status) {
    case 'idle':
      return event.type === 'FETCH' ? { status: 'loading' } : state;
    case 'loading':
      if (event.type === 'SUCCESS') return { status: 'success', data: event.data };
      if (event.type === 'FAIL') return { status: 'error', error: event.error };
      return state;
    case 'success':
      return event.type === 'FETCH' ? { status: 'loading' } : state;
    case 'error':
      return event.type === 'RETRY' || event.type === 'FETCH'
        ? { status: 'loading' }
        : state;
    default:
      return assertNever(state);
  }
}

3. From { status: 'success', data: 'x' }, show that transition(s, { type: 'FAIL', error: 'nope' }) does not corrupt into an error while keeping success data — it should stay success {Từ { status: 'success', data: 'x' }, chứng minh transition(s, { type: 'FAIL', error: 'nope' }) không biến thành lỗi mà vẫn giữ data success}.

Solution {Lời giải}
const s: FetchState = { status: 'success', data: 'x' };
const next = transition(s, { type: 'FAIL', error: 'nope' });
console.log(next.status === 'success' && next.data === 'x'); // true — illegal event ignored

4. Add a CheckoutState with at least three steps and reject an illegal jump: from { step: 'cart' } dispatch { type: 'PAY', orderId: '1' } and assert the state is still cart {Thêm CheckoutState ít nhất ba bước và từ chối nhảy bất hợp pháp: từ { step: 'cart' } dispatch { type: 'PAY', orderId: '1' } và assert state vẫn cart}.

Solution {Lời giải}
type CheckoutState =
  | { step: 'cart' }
  | { step: 'shipping'; addressId: string }
  | { step: 'payment'; addressId: string }
  | { step: 'done'; orderId: string };

type CheckoutEvent =
  | { type: 'SELECT_ADDRESS'; addressId: string }
  | { type: 'CONFIRM_SHIPPING' }
  | { type: 'PAY'; orderId: string };

function checkoutTransition(state: CheckoutState, event: CheckoutEvent): CheckoutState {
  switch (state.step) {
    case 'cart':
      if (event.type === 'SELECT_ADDRESS') {
        return { step: 'shipping', addressId: event.addressId };
      }
      return state;
    case 'shipping':
      if (event.type === 'CONFIRM_SHIPPING') {
        return { step: 'payment', addressId: state.addressId };
      }
      return state;
    case 'payment':
      if (event.type === 'PAY') return { step: 'done', orderId: event.orderId };
      return state;
    case 'done':
      return state;
    default: {
      const _never: never = state;
      return _never;
    }
  }
}

const cart: CheckoutState = { step: 'cart' };
const blocked = checkoutTransition(cart, { type: 'PAY', orderId: '1' });
console.log(blocked.step === 'cart'); // true

5. List one symptom that you still have “boolean soup” after introducing a union, and the fix {Nêu một triệu chứng cho thấy vẫn còn “boolean soup” sau khi có union, và cách sửa}.

Solution {Lời giải}

Symptom: status: 'loading' but UI still reads if (isError) showBanner() {Triệu chứng: status: 'loading' nhưng UI vẫn if (isError) showBanner()}. Fix: delete legacy flags; derive all UI from state.status only (one switch) {Sửa: xóa cờ cũ; mọi UI chỉ từ state.status (một switch)}.

Stretch {Nâng cao}: split connection (offline | online) and fetch into two small machines composed in a parent AppState — explain why that beats one mega-union with twelve tags {tách connectionfetch thành hai máy nhỏ gộp trong AppState cha — giải thích vì sao tốt hơn một mega-union mười hai tag}.

Solution {Lời giải}
type Connection = { status: 'offline' } | { status: 'online' };
type Fetch = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: string };

type AppState = { connection: Connection; fetch: Fetch };

// UI: spinner only when fetch.status === 'loading'
// Banner offline only when connection.status === 'offline'
// — no single field must encode both dimensions

Composing orthogonal dimensions avoids combinatorial explosion (offline+loading+error+…) and keeps each transition small {Gộp chiều trực giao tránh bùng nổ tổ hợp và giữ mỗi transition nhỏ}.


Key takeaways {Điểm chính}

  • Parallel booleans allow impossible combos; a discriminated union makes one status authoritative {Boolean song song cho phép combo không thể; discriminated union làm một status là nguồn sự thật}.
  • transition(state, event) encodes allowed edges; keep it pure, effects outside {transition(state, event) mã hóa cạnh hợp lệ; giữ thuần, effect ở ngoài}.
  • Exhaustive switch + never catches new states at compile time {switch exhaustive + never bắt state mới lúc biên dịch}.
  • State classes for chunky OOP behavior; union + transition for UI, async, and serializable flows {Class State cho hành vi OOP dày; union + transition cho UI, async, flow serialize được}.
  • Libraries help when the graph is hierarchical or parallel; hand-rolled is fine for small machines {Thư viện giúp khi đồ thị phân cấp hoặc song song; tự viết ổn cho máy nhỏ}.

Next up {Tiếp theo}

Part 10 — Proxy & Dependency Injection: control access to expensive or remote objects, swap implementations in tests, and wire dependencies at the composition root without singleton soup {Phần 10 — Proxy & Dependency Injection: kiểm soát truy cập object đắt hoặc xa, đổi implementation khi test, và nối dependency ở composition root không drowning singleton}. Continue to Part 10 — Proxy & Dependency Injection. ← Part 8 — Command & Memento