Bài 21.2: Typing actions và reducers

Trong hành trình khám phá Redux kết hợp với TypeScript, chúng ta đã thấy TypeScript mang lại sự an toàn và rõ ràng như thế nào cho trạng thái (state) của ứng dụng. Tuy nhiên, state chỉ là một nửa câu chuyện. Nửa còn lại là cách chúng ta thay đổi state đó – thông qua actionsreducers. Việc định kiểu (typing) cho actions và reducers là thiết yếu để tận dụng trọn vẹn sức mạnh của TypeScript trong hệ sinh thái Redux.

Tại sao việc này lại quan trọng? Hãy tưởng tượng một action mà bạn gửi đi mang theo dữ liệu không đúng định dạng, hoặc một reducer mong đợi một loại action nhưng lại nhận một loại khác. Trong JavaScript thuần, những lỗi này chỉ xuất hiện khi code chạy, thường là lúc người dùng đang sử dụng ứng dụng của bạn. Với TypeScript, chúng ta có thể phát hiện và sửa chữa phần lớn các lỗi ngay trong quá trình phát triển, trước khi chúng kịp gây rắc rối.

Bài viết này sẽ đi sâu vào cách chúng ta có thể định kiểu một cách hiệu quả cho actions và reducers, biến chúng từ những đối tượng và hàm "mờ ảo" thành những thành phần có cấu trúc rõ ràng, giúp code của chúng ta trở nên mạnh mẽ, dễ hiểu và dễ bảo trì hơn bao giờ hết.

Vấn đề: Sự "Vô Định" Của Action và Reducer Trong JavaScript

Trong JavaScript truyền thống, một action chỉ đơn giản là một đối tượng. Nó thường có thuộc tính type là một chuỗi, và có thể có thuộc tính payload chứa dữ liệu.

// Một action trong JS
const addTodoAction = {
  type: 'ADD_TODO',
  payload: {
    text: 'Tìm hiểu Typing trong Redux'
  }
};

// Một reducer cơ bản trong JS
function todoReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      // Làm gì đó với state và action.payload.text
      return [...state, action.payload.text];
    case 'REMOVE_TODO':
      // Làm gì đó với state và action.payload.id
      return state.filter(todo => todo.id !== action.payload.id);
    default:
      return state;
  }
}

Nhìn vào code trên, có những điểm yếu rõ ràng:

  1. Không có gì đảm bảo action.payload tồn tại, hoặc có cấu trúc như reducer mong đợi (payload.text hay payload.id).
  2. Nếu gõ sai tên type ('ADD_TOD' thay vì 'ADD_TODO'), reducer sẽ không nhận diện được action đó, và bạn sẽ không biết lỗi này cho đến khi chạy code và action đó được gửi đi.
  3. Reducer mong đợi state có cấu trúc gì? Nó có phải là một mảng không? Các phần tử trong mảng có cấu trúc gì? Không rõ ràng.

Đây chính là lúc TypeScript bước vào và giải cứu chúng ta bằng cách áp đặt cấu trúc (types) lên những khái niệm này.

Định Kiểu Cho Actions: Định Hình "Thông Điệp"

Action là trung tâm của luồng dữ liệu trong Redux. Chúng là những đối tượng đơn giản mô tả "điều gì đã xảy ra". Với TypeScript, chúng ta định nghĩa rõ ràng cấu trúc của từng loại action.

Cách phổ biến nhất là sử dụng interface hoặc type để định nghĩa cấu trúc của từng loại action cụ thể.

// Ví dụ về state (đã được định kiểu từ bài trước)
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
}

// Định nghĩa kiểu cho các actions

// Action thêm một Todo mới
interface AddTodoAction {
  type: 'ADD_TODO'; // Sử dụng literal type cho type
  payload: { // Định nghĩa cấu trúc payload
    id: number;
    text: string;
  };
}

// Action chuyển đổi trạng thái hoàn thành của Todo
interface ToggleTodoAction {
  type: 'TOGGLE_TODO'; // Literal type
  payload: { // Định nghĩa cấu trúc payload
    id: number;
  };
}

// Action xóa một Todo
interface RemoveTodoAction {
  type: 'REMOVE_TODO'; // Literal type
  payload: {
    id: number;
  };
}
  • Giải thích code:
    • Chúng ta định nghĩa các interface riêng biệt cho từng loại action: AddTodoAction, ToggleTodoAction, RemoveTodoAction.
    • Thuộc tính type được định nghĩa bằng literal type ('ADD_TODO', 'TOGGLE_TODO'). Điều này cực kỳ quan trọngTypeScript sẽ coi đây là những giá trị chuỗi cụ thể, chứ không phải chỉ là một string bất kỳ. Điều này giúp TypeScript phân biệt các loại action dựa trên giá trị của type.
    • Thuộc tính payload (hoặc bất kỳ thuộc tính dữ liệu nào khác) cũng được định nghĩa rõ ràng cấu trúc bên trong. AddTodoAction mong đợi payloadidnumbertextstring. ToggleTodoActionRemoveTodoAction chỉ cần idnumber.

Bây giờ, khi tạo một action, TypeScript sẽ kiểm tra xem nó có tuân thủ một trong các interface này không.

// ✅ Đúng kiểu AddTodoAction
const validAddAction: AddTodoAction = {
  type: 'ADD_TODO',
  payload: { id: 1, text: 'Học TypeScript' }
};

// ❌ Lỗi TypeScript! type phải là 'ADD_TODO'
// const invalidAddActionType: AddTodoAction = {
//   type: 'ADD_TOD',
//   payload: { id: 2, text: 'Sai type' }
// };

// ❌ Lỗi TypeScript! payload không có text
// const invalidAddActionPayload: AddTodoAction = {
//   type: 'ADD_TODO',
//   payload: { id: 3 }
// };
  • Giải thích code:
    • Khi gán giá trị cho biến được định kiểu là AddTodoAction, TypeScript sẽ kiểm tra.
    • Ví dụ invalidAddActionType báo lỗi vì giá trị của type không khớp với literal type 'ADD_TODO' đã định nghĩa trong AddTodoAction.
    • Ví dụ invalidAddActionPayload báo lỗi vì payload không có thuộc tính text kiểu string như đã định nghĩa.

Điều này giúp bạn phát hiện lỗi cú pháp hoặc lỗi logic cơ bản trong việc tạo action ngay lập tức.

Định Kiểu Cho Action Creators: Hàm Tạo Action An Toàn

Action creators là các hàm đơn giản trả về các đối tượng action. Việc định kiểu cho chúng cũng rất thẳng thắn.

// Action Creator cho AddTodoAction
const addTodo = (id: number, text: string): AddTodoAction => ({
  type: 'ADD_TODO',
  payload: { id, text }
});

// Action Creator cho ToggleTodoAction
const toggleTodo = (id: number): ToggleTodoAction => ({
  type: 'TOGGLE_TODO',
  payload: { id }
});
  • Giải thích code:
    • Chúng ta định nghĩa các tham số của hàm với kiểu dữ liệu rõ ràng (id: number, text: string).
    • Quan trọng nhất là định nghĩa kiểu trả về của hàm (: AddTodoAction, : ToggleTodoAction). TypeScript sẽ đảm bảo rằng giá trị mà hàm trả về phải phù hợp với interface action tương ứng.
    • Nếu bạn cố gắng trả về một đối tượng không khớp với kiểu đã khai báo, TypeScript sẽ báo lỗi.
// ✅ Sử dụng action creator đã được định kiểu
const myNewTodoAction = addTodo(4, 'Viết Blog về Redux Typing'); // TypeScript biết đây là AddTodoAction

// ❌ Lỗi TypeScript! Thiếu tham số text
// const incompleteAction = addTodo(5);

// ❌ Lỗi TypeScript! Tham số id phải là number
// const wrongTypeAction = toggleTodo('abc');
  • Giải thích code:
    • TypeScript kiểm tra các tham số bạn truyền vào hàm addTodotoggleTodo.
    • Nó cũng biết chính xác kiểu trả về của addTodoAddTodoAction.
    • Các ví dụ lỗi minh họa cách TypeScript ngăn chặn việc gọi hàm sai tham số hoặc sử dụng giá trị sai kiểu.

Định Kiểu Cho Reducers: Xử Lý Action Có Kiểm Soát

Đây là nơi TypeScript thực sự tỏa sáng khi kết hợp với Redux. Reducer là hàm nhận state hiện tại và action, sau đó trả về state mới.

// Nhắc lại kiểu State
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
}

// Định nghĩa kiểu State mặc định
const initialState: TodoState = {
  todos: []
};

Chữ ký hàm (function signature) của reducer cần được định kiểu. Nó nhận state kiểu TodoState (với giá trị mặc định) và action. Nhưng kiểu của action là gì?

Nếu chúng ta dùng AnyAction từ thư viện redux, chúng ta lại mất đi sự kiểm soát mà chúng ta vừa xây dựng ở trên:

import { AnyAction } from 'redux';

// Không nên dùng AnyAction ở đây!
const todoReducer = (state: TodoState = initialState, action: AnyAction): TodoState => {
  // ... code xử lý
  return state;
};
  • Giải thích code: Sử dụng AnyAction nói với TypeScript rằng action có thể là bất cứ thứ gì. Điều này cho phép bạn truy cập action.type, action.payload, hay bất kỳ thuộc tính nào khác mà không bị báo lỗi, nhưng đồng thời cũng không có sự an toàn kiểu nào cả.

Để TypeScript có thể giúp ích trong reducer, chúng ta cần cho nó biết action có thể là tất cả các loại action cụ thể mà reducer này quan tâm. Đây là lúc Union Types phát huy tác dụng.

Chúng ta tạo một union type bao gồm tất cả các interface action mà reducer này có thể xử lý:

// Nhắc lại các kiểu Action
interface AddTodoAction {
  type: 'ADD_TODO';
  payload: { id: number; text: string };
}

interface ToggleTodoAction {
  type: 'TOGGLE_TODO';
  payload: { id: number };
}

interface RemoveTodoAction {
  type: 'REMOVE_TODO';
  payload: { id: number };
}

// Tạo Union Type của TẤT CẢ các action có thể có
type TodoActionTypes =
  | AddTodoAction
  | ToggleTodoAction
  | RemoveTodoAction; // Thêm các loại action khác nếu có
  • Giải thích code: TodoActionTypes bây giờ là một kiểu dữ liệu có thể là AddTodoAction hoặc ToggleTodoAction hoặc RemoveTodoAction.

Bây giờ, chúng ta sử dụng union type này cho tham số action trong chữ ký của reducer:

const todoReducer = (state: TodoState = initialState, action: TodoActionTypes): TodoState => {
  // Code xử lý action
  switch (action.type) {
    case 'ADD_TODO':
      // ... logic
      return state; // Trả về state mới
    case 'TOGGLE_TODO':
      // ... logic
      return state; // Trả về state mới
    case 'REMOVE_TODO':
      // ... logic
      return state; // Trả về state mới
    default:
      return state;
  }
};
  • Giải thích code: Chữ ký hàm bây giờ rất rõ ràng: nó nhận state kiểu TodoStateaction kiểu TodoActionTypes, và phải trả về state kiểu TodoState.

Sức Mạnh Của Discriminated Unions Trong Reducers

Khi sử dụng switch statement trên thuộc tính type của actionaction đó là một union type có thuộc tính phân biệt (discriminant property) như type, TypeScript trở nên cực kỳ thông minh. Đây được gọi là Discriminated Unions.

Bên trong mỗi case của switch, TypeScript biết chính xác loại action cụ thể nào đang được xử lý, và do đó, nó biết chính xác cấu trúc của thuộc tính payload (hoặc các thuộc tính khác) của action đó.

Hãy viết lại reducer với logic xử lý cụ thể:

const todoReducer = (state: TodoState = initialState, action: TodoActionTypes): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      // Ở đây, TypeScript biết 'action' chắc chắn là 'AddTodoAction'
      // vì 'action.type' === 'ADD_TODO'.
      // Do đó, nó biết 'action.payload' có thuộc tính 'id' và 'text'.
      return {
        ...state,
        todos: [...state.todos, {
          id: action.payload.id, // ✅ TypeScript cho phép truy cập action.payload.id
          text: action.payload.text, // ✅ TypeScript cho phép truy cập action.payload.text
          completed: false
        }]
      };

    case 'TOGGLE_TODO':
      // Ở đây, TypeScript biết 'action' chắc chắn là 'ToggleTodoAction'
      // vì 'action.type' === 'TOGGLE_TODO'.
      // Do đó, nó biết 'action.payload' chỉ có thuộc tính 'id'.
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id // ✅ TypeScript cho phép truy cập action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };

    case 'REMOVE_TODO':
      // Ở đây, TypeScript biết 'action' chắc chắn là 'RemoveTodoAction'.
      // Do đó, nó biết 'action.payload' chỉ có thuộc tính 'id'.
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload.id) // ✅ TypeScript cho phép truy cập action.payload.id
      };

    default:
      // Nếu bạn sử dụng một linter rule như 'no-fallthrough-cases-in-switch'
      // hoặc 'exhaustive-deps' cho các case, TypeScript có thể báo lỗi
      // nếu bạn thiếu một case cho một loại action trong TodoActionTypes.
      // Trong trường hợp này, nó chỉ đơn giản là trả về state hiện tại.
      return state;
  }
};
  • Giải thích code:
    • Bên trong case 'ADD_TODO', TypeScript tự động thu hẹp kiểu của biến action từ TodoActionTypes (có thể là 3 loại khác nhau) xuống chỉ còn AddTodoAction. Điều này có nghĩa là bạn có thể truy cập action.payload.idaction.payload.text một cách an toàn, và TypeScript sẽ báo lỗi nếu bạn cố gắng truy cập một thuộc tính không tồn tại trên AddTodoAction (ví dụ: action.payload.somethingElse).
    • Tương tự, bên trong case 'TOGGLE_TODO'case 'REMOVE_TODO', TypeScript biết action chỉ là ToggleTodoAction hoặc RemoveTodoAction, cho phép bạn truy cập action.payload.id nhưng sẽ báo lỗi nếu bạn cố gắng truy cập action.payload.text.

Đây là lợi ích lớn nhất của việc định kiểu action với literal type và kết hợp chúng trong union type cho reducer. Nó cung cấp sự an toàn kiểu mạnh mẽ ngay trong logic xử lý của reducer.

Các Phương Pháp Tốt Hơn Cho Action Types

Việc sử dụng literal type trực tiếp ('ADD_TODO') là tốt, nhưng khi số lượng action tăng lên, việc gõ lại chuỗi có thể dẫn đến lỗi chính tả. Có hai cách phổ biến để quản lý các chuỗi type này một cách an toàn hơn trong TypeScript:

  1. Sử dụng as const với Object:

    const TodoActionTypes = {
      ADD_TODO: 'ADD_TODO',
      TOGGLE_TODO: 'TOGGLE_TODO',
      REMOVE_TODO: 'REMOVE_TODO',
    } as const; // Đây là key!
    
    // Lấy ra union type của các giá trị chuỗi từ object trên
    type TodoActionTypeValues = typeof TodoActionTypes[keyof typeof TodoActionTypes]; // Kết quả: 'ADD_TODO' | 'TOGGLE_TODO' | 'REMOVE_TODO'
    
    // Định nghĩa lại actions sử dụng kiểu này
    interface AddTodoAction {
      type: typeof TodoActionTypes.ADD_TODO; // Sử dụng giá trị từ object
      payload: { id: number; text: string };
    }
    
    interface ToggleTodoAction {
      type: typeof TodoActionTypes.TOGGLE_TODO;
      payload: { id: number };
    }
    
    interface RemoveTodoAction {
      type: typeof TodoActionTypes.REMOVE_TODO;
      payload: { id: number };
    }
    
    // Union type vẫn tạo ra như cũ
    type TodoActions =
      | AddTodoAction
      | ToggleTodoAction
      | RemoveTodoAction;
    
    • Giải thích code:
      • Chúng ta định nghĩa các chuỗi type trong một object TodoActionTypes.
      • Việc thêm as const ở cuối object là cực kỳ quan trọng. Nó nói với TypeScript rằng các giá trị của object này không phải là string chung chung, mà là các literal type cụ thể ('ADD_TODO', 'TOGGLE_TODO').
      • Chúng ta sử dụng typeof TodoActionTypes[keyof typeof TodoActionTypes] để tự động tạo ra union type của các giá trị trong object đó.
      • Khi định nghĩa interface cho actions, chúng ta tham chiếu đến các giá trị cụ thể trong object (typeof TodoActionTypes.ADD_TODO). Điều này đảm bảo rằng nếu bạn đổi chuỗi 'ADD_TODO' trong object, kiểu của action cũng sẽ tự động cập nhật.
  2. Sử dụng enum:

    enum TodoActionTypeEnum {
      ADD_TODO = 'ADD_TODO',
      TOGGLE_TODO = 'TOGGLE_TODO',
      REMOVE_TODO = 'REMOVE_TODO',
    }
    
    // Định nghĩa lại actions sử dụng enum
    interface AddTodoAction {
      type: TodoActionTypeEnum.ADD_TODO; // Sử dụng giá trị từ enum
      payload: { id: number; text: string };
    }
    
    interface ToggleTodoAction {
      type: TodoActionTypeEnum.TOGGLE_TODO;
      payload: { id: number };
    }
    
    interface RemoveTodoAction {
      type: TodoActionTypeEnum.REMOVE_TODO;
      payload: { id: number };
    }
    
    // Union type của actions sử dụng các interface đã cập nhật
    type TodoActions =
      | AddTodoAction
      | ToggleTodoAction
      | RemoveTodoAction;
    
    • Giải thích code:
      • enum cung cấp một cách khác để quản lý các hằng số chuỗi. Các thành viên của enum cũng hoạt động tốt với Discriminated Unions.
      • Cách sử dụng tương tự như dùng object với as const: tham chiếu đến giá trị cụ thể trong enum khi định nghĩa kiểu type của action.

Cả hai phương pháp này đều giúp tập trung định nghĩa các chuỗi action type vào một nơi duy nhất, giảm thiểu lỗi chính tả và cải thiện khả năng refactor code. Tùy chọn nào phụ thuộc vào sở thích cá nhân và quy ước của team bạn. Phương pháp as const thường được ưa chuộng hơn trong cộng đồng React/Redux vì nó không tạo ra code JavaScript "thừa" ở runtime như enum.

Comments

There are no comments at the moment.