Bài 21.2: Typing actions và reducers

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 actions và reducers. 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:
- 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
haypayload.id
). - 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. - 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ọng vì TypeScript sẽ coi đây là những giá trị chuỗi cụ thể, chứ không phải chỉ là mộtstring
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ủatype
. - 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 đợipayload
cóid
lànumber
vàtext
làstring
.ToggleTodoAction
vàRemoveTodoAction
chỉ cầnid
lànumber
.
- Chúng ta định nghĩa các
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ủatype
không khớp với literal type'ADD_TODO'
đã định nghĩa trongAddTodoAction
. - Ví dụ
invalidAddActionPayload
báo lỗi vìpayload
không có thuộc tínhtext
kiểustring
như đã định nghĩa.
- Khi gán giá trị cho biến được định kiểu là
Đ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.
- Chúng ta định nghĩa các tham số của hàm với kiểu dữ liệu rõ ràng (
// ✅ 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
addTodo
vàtoggleTodo
. - Nó cũng biết chính xác kiểu trả về của
addTodo
làAddTodoAction
. - 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.
- TypeScript kiểm tra các tham số bạn truyền vào hàm
Đị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ằngaction
có thể là bất cứ thứ gì. Điều này cho phép bạn truy cậpaction.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ặcToggleTodoAction
hoặcRemoveTodoAction
.
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ểuTodoState
vàaction
kiểuTodoActionTypes
, và phải trả vềstate
kiểuTodoState
.
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 action
mà action
đó 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ếnaction
từTodoActionTypes
(có thể là 3 loại khác nhau) xuống chỉ cònAddTodoAction
. Điều này có nghĩa là bạn có thể truy cậpaction.payload.id
vàaction.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ênAddTodoAction
(ví dụ:action.payload.somethingElse
). - Tương tự, bên trong
case 'TOGGLE_TODO'
vàcase 'REMOVE_TODO'
, TypeScript biếtaction
chỉ làToggleTodoAction
hoặcRemoveTodoAction
, cho phép bạn truy cậpaction.payload.id
nhưng sẽ báo lỗi nếu bạn cố gắng truy cậpaction.payload.text
.
- Bên trong
Đâ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:
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.
- Chúng ta định nghĩa các chuỗi type trong một object
- Giải thích code:
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ủaenum
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ể trongenum
khi định nghĩa kiểutype
của action.
- Giải thích code:
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