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 {State và má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ố định và chỉ 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}.
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ế}
| Area | States (examples) | Why FSM helps |
|---|---|---|
| Async data | idle → loading → success | error | No loading && error; refetch is an explicit event |
| Form wizard | step1 → step2 → review → submitted | Back/next only where allowed |
| Auth / session | anonymous → authenticating → authenticated | expired | Route guards key off one status |
| Media player | paused ↔ playing → ended | Controls map 1:1 to transitions |
| Connection | offline → connecting → online | Reconnect policy per edge, not random timers |
| Toggle / traffic light | red → green → yellow | UI 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 trongtransition— khó test và replay; chạy effect sau bước thuần}. - State explosion — ten booleans become 2¹⁰ nodes; collapse dimensions (e.g. separate
connectionfromfetch) or use hierarchical charts {Bùng nổ state — mười boolean thành 2¹⁰ nút; gộp chiều (táchconnectionkhỏifetch) hoặc dùng biểu đồ phân cấp}. - Forgetting terminal states —
doneshould not acceptPAY; document sinks explicitly {Quên state kết —donekhông nhậnPAY; 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 ignored4. 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'); // true5. 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 connection và fetch 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 dimensionsComposing 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
statuslà 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+nevercatches new states at compile time {switchexhaustive +neverbắ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