Ngừng Dùng Cờ Boolean Cho UI State Trong TypeScript

Ngừng Dùng Cờ Boolean Cho UI State Trong TypeScript

Mệt mỏi với các cờ isLoading và isError lộn xộn? Học cách dùng TypeScript Discriminated Unions để loại bỏ các state bất khả thi và viết code an toàn hơn.

Bạn đã bao giờ nhìn vào state của một component và thấy một mớ cờ boolean lộn xộn như isLoading, isError, và isSuccess chưa? Tôi thì gặp hoài. Đây là một trong những pattern phổ biến nhất trong frontend, đặc biệt là khi gọi API lấy dữ liệu. Nhưng nó cũng là một cái bẫy dẫn đến code mỏng manh và những bug UI khó đoán.

Hôm nay, hãy cùng bàn về lý do tại sao phụ thuộc vào nhiều cờ boolean lại là một ý tồi, và cách chúng ta có thể sử dụng Discriminated Unions trong TypeScript để làm cho việc quản lý state trở nên bất khả chiến bại.

Vấn đề của các cờ Boolean

Hãy xem xét một kịch bản lấy dữ liệu điển hình trong một ứng dụng React hoặc Vue. Bạn có thể định nghĩa interface cho state của mình như thế này:

interface UserState {
  data: User | null;
  isLoading: boolean;
  isError: boolean;
  errorMessage: string | null;
}

Thoạt nhìn, cách này có vẻ hoàn toàn ổn. Bạn set isLoading thành true khi bắt đầu fetch, sau đó cập nhật data hoặc isError khi hoàn thành.

Nhưng chuyện gì sẽ xảy ra nếu một bug trong logic của bạn set cả isLoading: trueisError: true cùng một lúc? Hoặc điều gì xảy ra nếu data đã có dữ liệu, nhưng isError vẫn là true từ một request lỗi trước đó chưa được xóa đúng cách?

Những trạng thái này được gọi là impossible states (trạng thái bất khả thi). Về mặt logic, ứng dụng của bạn không bao giờ được ở trong trạng thái vừa đang load dữ liệu ban đầu, lại vừa hiển thị một lỗi nghiêm trọng. Tuy nhiên, hệ thống type của bạn lại cho phép điều đó một cách hợp lệ. Khi type system cho phép các trạng thái bất khả thi, bạn sẽ bị buộc phải viết những đoạn logic render if/else phòng thủ cực kỳ phức tạp trong component chỉ để xác định xem chuyện quái gì đang thực sự xảy ra.

// Chúc may mắn khi maintain đoạn logic này...
if (isLoading && !isError) {
  return <Spinner />;
} else if (isError) {
  return <ErrorMessage message={errorMessage} />;
} else if (data) {
  return <UserProfile user={data} />;
} else {
  return null; // Làm sao code lại chạy vào tới đây được nhỉ?
}

Giải pháp: Discriminated Unions

Một người đứng trước nhiều biển báo rối rắm tượng trưng cho những trạng thái bất khả thi

Thay vì mô tả state bằng các boolean độc lập có thể xung đột với nhau, chúng ta nên mô tả state như một tập hợp các khả năng loại trừ lẫn nhau. Trong TypeScript, chúng ta làm điều này bằng cách sử dụng Discriminated Unions.

Nói một cách đơn giản, discriminated union là sự kết hợp (union) của các object type, trong đó tất cả đều chia sẻ một thuộc tính chung—gọi là "discriminant" (đặc điểm nhận dạng).

Hãy viết lại UserState của chúng ta, sử dụng thuộc tính status làm discriminant:

type UserState = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; errorMessage: string };

Nhìn xem nó rõ ràng hơn biết bao nhiêu!

  1. Nếu status là 'idle' hoặc 'loading', chúng ta thậm chí không thể truy cập vào data hay errorMessage.
  2. Nếu status là 'success', TypeScript đảm bảo với ta rằng data chắc chắn tồn tại.
  3. Nếu status là 'error', chúng ta chắc chắn sẽ có errorMessage.

Chúng ta đã hoàn toàn loại bỏ khả năng xảy ra các "impossible states" ngay từ cấp độ type.

Áp dụng vào thực tế

Lưới an toàn bắt lấy đồ vật, tượng trưng cho sự bảo vệ của TypeScript compiler

Khi bạn sử dụng pattern này, TypeScript trở thành một trợ thủ đắc lực. Nó sẽ tự động thu hẹp (narrow down) type dựa trên câu lệnh check status, cung cấp autocompletion chính xác và bắt lỗi ngay lúc compile.

Dưới đây là cách bạn có thể xử lý state này trong một component:

function UserProfileComponent({ state }: { state: UserState }) {
  switch (state.status) {
    case 'idle':
      return <p>Sẵn sàng để tải dữ liệu user.</p>;
    case 'loading':
      return <Spinner />;
    case 'error':
      // TypeScript biết chắc chắn 'errorMessage' tồn tại ở đây
      return <Error message={state.errorMessage} />;
    case 'success':
      // TypeScript biết chắc chắn 'data' tồn tại ở đây
      return <Profile data={state.data} />;
  }
}

Bạn thấy không? Chúng ta không cần bất kỳ chuỗi if/else phức tạp nào để kiểm tra nhiều biến nữa. Logic chảy rất tự nhiên, và compiler đảm bảo chúng ta không thể vô tình cố gắng render state.data khi status đang là 'error'.

Exhaustive Checking: Kỹ thuật cho code "chống đạn"

Một trong những tính năng tuyệt vời nhất của discriminated unions là khả năng "exhaustive checking" (kiểm tra toàn diện). Chuyện gì sẽ xảy ra nếu một ngày đẹp trời, đồng nghiệp của bạn thêm một state mới, ví dụ như { status: 'revalidating'; data: User }, vào union UserState?

Nếu họ quên cập nhật câu lệnh switch trong UserProfileComponent, TypeScript sẽ không phàn nàn ngay lập tức trừ khi chúng ta yêu cầu nó. Chúng ta có thể ép buộc điều này bằng cách sử dụng type never trong case default của lệnh switch.

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

function UserProfileComponent({ state }: { state: UserState }) {
  switch (state.status) {
    // ... xử lý idle, loading, error, success ...
    default:
      // Nếu một status mới được thêm vào UserState nhưng không được handle
      // ở switch này, TypeScript sẽ báo lỗi compile ngay tại đây.
      return assertNever(state);
  }
}

Bằng cách thêm hàm assertNever đơn giản này, chúng ta đã biến một runtime bug tiềm ẩn (thiếu UI cho một state) thành một lỗi compile-time nghiêm ngặt. Nếu bạn thêm một state mới, compiler sẽ ngay lập tức chỉ cho bạn chính xác những lệnh switch nào trong toàn bộ dự án cần được cập nhật.

Tổng kết

Các cờ boolean như isLoading hay isError rất dễ dùng, nhưng chúng không phản ánh chính xác thế giới thực. Chúng tạo ra một ma trận các state tiềm năng, trong đó có rất nhiều state không hợp lệ, dẫn đến logic UI lỏng lẻo và dễ vỡ.

Bằng cách chuyển sang sử dụng TypeScript Discriminated Unions, chúng ta làm cho code sát với logic nghiệp vụ thực tế hơn. Ta loại bỏ các impossible states, đơn giản hóa logic render, và nhận được sự hỗ trợ tuyệt vời hơn nhiều từ TypeScript compiler. Đó chỉ là một thay đổi nhỏ trong cách bạn định nghĩa type, nhưng lại mang đến tác động to lớn đến khả năng bảo trì của ứng dụng.

Hãy thử refactor một component phức tạp trong dự án của bạn để dùng pattern này xem, bạn sẽ thấy sự khác biệt ngay lập tức.

NT

viết bởi

Nguyên Tech

0

Phản hồi

Đang tải bình luận…

Lattice.

Một không gian để viết dài, đọc chậm, và trò chuyện thật — không thuật toán, không quảng cáo.

© 2026 · Lattice · Đà Nẵng (16°03′ N, 108°12′ E) · v0.1 · system + ink + indigo