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.

Phần 9/10 trong series Design Patterns in TypeScript. Trước: Tiếp:

Đâ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. Ở Phần 8 — Command & Memento bạn biến hành động thành object có thể xếp hàng và undo; ở đâ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ệ.

Mùi quen thuộc: isLoading, isError, data, và error là các boolean rời. Không gì ngăn isLoading === true && isError === true khi data === undefined — màn hình vừa spinner vừa banner lỗi. 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.


Ý đồ

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ồ. 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. 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 = hành vi thay theo state. FSM = nút và cạnh rõ ràng. 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.


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

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 không optional trên cả object — nó chỉ có trong success. Bạn không đọc state.data trong loading mà compiler không chặn. Đó là làm state bất hất pháp không biểu diễn được trong thực tế.


Hàm chuyển tiếp có kiểu

Mô hình event tách khỏi state. 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 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ý. Giữ side effect (fetch, analytics, toast) ở lớp dispatch event, không trong transition — transition dễ unit test.

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

Bạn không nhảy từ cart sang done mà không đi qua cạnh đã khai báo.


State pattern kinh điển (object)

Ả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;
  }
}

Hợp khi hành vi nặng và hướng object (nhiều method mỗi state). 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õ. Trộn được: lưu { status: 'playing' } trong state, ủy quyền render cho hàm nhỏ theo status.


Khi nào dùng thư viện

Union tự viết đủ cho máy phẳng: trạng thái fetch, cổng auth, wizard đơn giản. 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. 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. Với fetch bốn state, transition 40 dòng thắng import runtime.


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

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. Phần 10 sẽ inject service dispatch event mà UI không cần biết chi tiết fetch.


Cạm bẫy

  • Giữ boolean cạnh union — bạn tái tạo combo không thể; xóa cờ.
  • 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 effect trong transition — khó test và replay; chạy effect sau bước thuần.
  • 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.
  • Quên state kếtdone không nhận PAY; ghi rõ sink.

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

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. 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;
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. Cài transition(state, event) cho máy fetch (FETCH, SUCCESS, FAIL, RETRY) và dùng assertNever ở nhánh default của state.

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. 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.

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. 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.

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. 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.

Lời giải

Triệu chứng: status: 'loading' nhưng UI vẫn if (isError) showBanner(). Sửa: xóa cờ cũ; mọi UI chỉ từ state.status (một switch).

Nâng cao: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.

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

Gộp chiều trực giao tránh bùng nổ tổ hợp và giữ mỗi transition nhỏ.


Điểm chính

  • 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) mã hóa cạnh hợp lệ; giữ thuần, effect ở ngoài.
  • switch exhaustive + never bắt state mới lúc biên dịch.
  • Class State cho hành vi OOP dày; union + transition cho UI, async, flow serialize được.
  • Thư viện giúp khi đồ thị phân cấp hoặc song song; tự viết ổn cho máy nhỏ.

Tiếp theo

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.