Bài 16.2: useReducer với TypeScript

Bài 16.2: useReducer với TypeScript
Chào mừng bạn đến với bài viết chuyên sâu về useReducer
kết hợp với TypeScript trong React!
Khi phát triển các ứng dụng React, việc quản lý trạng thái (state) là một phần không thể thiếu. Với các trạng thái đơn giản, hook useState
hoạt động rất hiệu quả. Tuy nhiên, khi logic cập nhật trạng thái trở nên phức tạp hơn, hoặc khi nhiều phần của trạng thái phụ thuộc lẫn nhau, useState
có thể dẫn đến những đoạn code khó đọc, khó kiểm thử và dễ gây lỗi. Đây chính là lúc useReducer
tỏa sáng, mang lại một cách tiếp cận có cấu trúc và dự đoán được hơn.
Và khi kết hợp useReducer
với sức mạnh tĩnh của TypeScript, chúng ta sẽ có một công cụ cực kỳ mạnh mẽ để xây dựng các ứng dụng mạnh mẽ, an toàn và dễ bảo trì.
useReducer
là gì và tại sao lại cần nó?
Hãy hình dung trạng thái của ứng dụng của bạn giống như một... trạng thái vậy (đúng như tên gọi!). Trạng thái này thay đổi theo thời gian dựa trên các hành động (actions) xảy ra trong ứng dụng (ví dụ: người dùng click nút, dữ liệu được fetch về).
useReducer
cung cấp một mô hình để quản lý sự thay đổi trạng thái này theo nguyên lý reducer function. Về cơ bản, reducer là một hàm nhận vào trạng thái hiện tại và một hành động, sau đó trả về trạng thái mới.
useReducer
rất hữu ích khi:
- Logic cập nhật trạng thái phức tạp, liên quan đến nhiều hành động khác nhau.
- Trạng thái bao gồm nhiều giá trị phụ thuộc hoặc liên quan đến nhau.
- Bạn muốn tách biệt logic cập nhật trạng thái ra khỏi component.
- Bạn muốn kiểm thử logic cập nhật trạng thái một cách dễ dàng hơn.
Cú pháp cơ bản của useReducer
:
const [state, dispatch] = useReducer(reducer, initialState);
reducer
: Là hàm reducer của bạn.initialState
: Là giá trị khởi tạo của trạng thái.state
: Là giá trị trạng thái hiện tại.dispatch
: Là một hàm bạn dùng để "gửi" (dispatch) các hành động đến reducer. Khi bạn gọidispatch(action)
, React sẽ chạy hàm reducer với trạng thái hiện tại vàaction
đó, sau đó cập nhật trạng thái component với giá trị trả về từ reducer.
Nâng cấp với TypeScript: Sự an toàn và rõ ràng
useReducer
đã tốt, nhưng khi làm việc với các dự án lớn hoặc đội nhóm, việc đảm bảo các hành động được gửi đi là hợp lệ và trạng thái có cấu trúc đúng là vô cùng quan trọng. TypeScript giúp chúng ta đạt được điều này bằng cách định nghĩa rõ ràng kiểu dữ liệu cho trạng thái và các hành động.
Định nghĩa kiểu dữ liệu cho State và Actions
Đây là bước đầu tiên và quan trọng nhất khi sử dụng useReducer
với TypeScript. Chúng ta cần định nghĩa:
- Kiểu dữ liệu cho trạng thái (State Type): Mô tả cấu trúc của toàn bộ trạng thái mà reducer quản lý.
- Kiểu dữ liệu cho các hành động (Action Types): Định nghĩa các loại hành động khác nhau mà reducer có thể xử lý, bao gồm cả dữ liệu (payload) đi kèm với hành động đó.
Một pattern phổ biến và mạnh mẽ cho Action Types là sử dụng Union Type (kiểu kết hợp) với một thuộc tính phân biệt (discriminant property), thường là type
.
Ví dụ về một counter đơn giản:
// 1. Định nghĩa kiểu dữ liệu cho State
interface CounterState {
count: number;
}
// 2. Định nghĩa kiểu dữ liệu cho Actions
// Sử dụng Union Type với 'type' là thuộc tính phân biệt
type CounterAction =
| { type: 'increment' } // Hành động tăng, không cần thêm dữ liệu
| { type: 'decrement' } // Hành động giảm, không cần thêm dữ liệu
| { type: 'reset', payload: number }; // Hành động reset, cần dữ liệu là giá trị mới
Trong ví dụ trên, CounterAction
có thể là một trong ba loại hành động được định nghĩa. TypeScript sẽ hiểu rằng nếu action.type
là 'reset'
, thì chắc chắn nó sẽ có thuộc tính payload
với kiểu number
. Điều này cực kỳ hữu ích trong hàm reducer!
Định nghĩa kiểu dữ liệu cho Reducer Function
Hàm reducer của bạn cần được gán kiểu dữ liệu phù hợp. Kiểu dữ liệu này sẽ mô tả hàm nhận vào State
và Action
, và trả về State
.
// 3. Định nghĩa kiểu dữ liệu và viết hàm Reducer
const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
// TypeScript biết rằng nếu action.type là 'reset', thì action sẽ có payload: number
return { count: action.payload };
default:
// Luôn trả về trạng thái hiện tại nếu hành động không được xử lý
return state;
}
};
Ở đây, chúng ta đã gán kiểu CounterState
cho tham số state
, CounterAction
cho tham số action
, và chỉ định kiểu trả về là CounterState
. Bên trong hàm, khi chúng ta xử lý case 'reset'
, TypeScript tự động thu hẹp kiểu của action
chỉ còn là { type: 'reset', payload: number }
, cho phép chúng ta truy cập an toàn vào action.payload
. Nếu bạn cố gắng truy cập action.payload
trong case 'increment'
, TypeScript sẽ báo lỗi ngay lập tức!
Sử dụng useReducer
với các kiểu đã định nghĩa
Cuối cùng, bạn sử dụng hook useReducer
và truyền các kiểu dữ liệu đã định nghĩa vào:
import React, { useReducer } from 'react';
// ... (Định nghĩa các kiểu CounterState và CounterAction ở trên)
// ... (Viết hàm counterReducer ở trên)
const CounterComponent: React.FC = () => {
// Sử dụng useReducer với reducer và trạng thái khởi tạo
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>Tăng</button>
<button onClick={() => dispatch({ type: 'decrement' })}>Giảm</button>
<button onClick={() => dispatch({ type: 'reset', payload: 0 })}>Reset về 0</button>
{/* TypeScript sẽ báo lỗi nếu bạn gửi một hành động không hợp lệ */}
{/* <button onClick={() => dispatch({ type: 'unknown' })}>Lỗi?</button> */}
{/* TypeScript cũng báo lỗi nếu bạn gửi hành động reset mà thiếu payload */}
{/* <button onClick={() => dispatch({ type: 'reset' })}>Lỗi payload?</button> */}
</div>
);
};
export default CounterComponent;
TypeScript tự động suy luận kiểu của state
là CounterState
và kiểu của dispatch
là một hàm nhận vào CounterAction
. Điều này có nghĩa là khi bạn gọi dispatch
, TypeScript sẽ kiểm tra xem đối số bạn truyền vào có khớp với một trong các loại trong CounterAction
hay không. Nếu không, bạn sẽ nhận được lỗi compile-time!
Ví dụ nâng cao hơn: Quản lý form
Hãy xem xét một ví dụ phức tạp hơn một chút: quản lý trạng thái của một form đơn giản.
import React, { useReducer, ChangeEvent } from 'react';
// 1. Định nghĩa kiểu dữ liệu cho State
interface FormState {
name: string;
email: string;
age: number | ''; // Cho phép rỗng ban đầu
isSubmitting: boolean;
error: string | null;
}
// 2. Định nghĩa kiểu dữ liệu cho Actions
// Sử dụng Union Type với thuộc tính 'type'
type FormAction =
| { type: 'SET_FIELD'; field: keyof FormState; value: any } // Action chung để set giá trị cho một trường
| { type: 'SUBMIT_START' } // Bắt đầu submit
| { type: 'SUBMIT_SUCCESS' } // Submit thành công
| { type: 'SUBMIT_ERROR'; payload: string } // Submit lỗi, cần thông báo lỗi
| { type: 'RESET_FORM' }; // Reset form
// Trạng thái khởi tạo
const initialFormState: FormState = {
name: '',
email: '',
age: '',
isSubmitting: false,
error: null,
};
// 3. Định nghĩa kiểu dữ liệu và viết hàm Reducer
const formReducer = (state: FormState, action: FormAction): FormState => {
switch (action.type) {
case 'SET_FIELD':
// TypeScript kiểm tra action.field có phải là key hợp lệ của FormState không
// TypeScript cũng kiểm tra action.value có tương thích với kiểu của trường đó không (đến một mức độ nào đó, 'any' ở đây cần cẩn thận hoặc dùng generic)
// Để an toàn hơn với 'SET_FIELD', bạn có thể cần action payload chi tiết hơn
// Ví dụ: action: { type: 'SET_NAME', value: string } | { type: 'SET_EMAIL', value: string } ...
// Tuy nhiên, cách dùng keyof State như trên là một pattern phổ biến cho form đơn giản
return {
...state,
[action.field]: action.value,
};
case 'SUBMIT_START':
return {
...state,
isSubmitting: true,
error: null, // Xóa lỗi cũ khi bắt đầu submit
};
case 'SUBMIT_SUCCESS':
return {
...initialFormState, // Reset form về trạng thái ban đầu
isSubmitting: false, // Dừng submitting
};
case 'SUBMIT_ERROR':
// TypeScript biết action.payload là string
return {
...state,
isSubmitting: false, // Dừng submitting
error: action.payload, // Lưu thông báo lỗi
};
case 'RESET_FORM':
return {
...initialFormState // Reset form hoàn toàn
}
default:
return state;
}
};
// Component sử dụng useReducer
const FormComponent: React.FC = () => {
const [state, dispatch] = useReducer(formReducer, initialFormState);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value, type } = e.target;
// Ép kiểu value tùy thuộc vào type của input
const fieldValue = type === 'number' ? (value === '' ? '' : Number(value)) : value;
// Dispatch action SET_FIELD
dispatch({
type: 'SET_FIELD',
field: name as keyof FormState, // Ép kiểu tên input thành key của FormState
value: fieldValue
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
// Giả lập một API call
try {
console.log('Submitting:', state);
await new Promise(resolve => setTimeout(resolve, 1000)); // Chờ 1 giây
if (state.name === 'error') { // Giả lập lỗi nếu tên là 'error'
throw new Error('Lỗi từ server!');
}
dispatch({ type: 'SUBMIT_SUCCESS' });
alert('Submit thành công!');
} catch (err: any) {
dispatch({ type: 'SUBMIT_ERROR', payload: err.message });
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Tên:</label>
<input
type="text"
id="name"
name="name"
value={state.name}
onChange={handleInputChange}
disabled={state.isSubmitting}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={state.email}
onChange={handleInputChange}
disabled={state.isSubmitting}
/>
</div>
<div>
<label htmlFor="age">Tuổi:</label>
<input
type="number"
id="age"
name="age"
value={state.age}
onChange={handleInputChange}
disabled={state.isSubmitting}
/>
</div>
{state.error && <p style={{ color: 'red' }}>Lỗi: {state.error}</p>}
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? 'Đang gửi...' : 'Gửi Form'}
</button>
<button type="button" onClick={() => dispatch({ type: 'RESET_FORM' })} disabled={state.isSubmitting}>
Reset
</button>
</form>
);
};
export default FormComponent;
Trong ví dụ form này, chúng ta thấy:
- Trạng thái (
FormState
) là một object phức tạp hơn. - Các hành động (
FormAction
) được định nghĩa rõ ràng, bao gồm cả hành động chungSET_FIELD
sử dụngkeyof FormState
để đảm bảo tên trường hợp lệ và các hành động dành riêng cho quy trình submit. - Hàm reducer xử lý các hành động này, cập nhật trạng thái một cách logic.
- TypeScript giúp kiểm tra tại compile-time rằng chúng ta chỉ dispatch các hành động đã định nghĩa và truyền đúng payload (ví dụ:
payload
trongSUBMIT_ERROR
phải là string).
Lợi ích khi sử dụng useReducer
với TypeScript
Tóm lại, việc kết hợp useReducer
và TypeScript mang lại nhiều lợi ích đáng kể:
- Code Rõ Ràng và Dễ Đọc Hơn: Bằng cách định nghĩa rõ ràng các kiểu State và Action, mã nguồn của bạn trở nên dễ hiểu hơn về cấu trúc dữ liệu và luồng cập nhật trạng thái.
- Phòng Ngừa Lỗi Compile-time: TypeScript bắt được nhiều lỗi tiềm ẩn ngay trong quá trình phát triển, chẳng hạn như gửi sai loại hành động, truy cập thuộc tính không tồn tại trên state hoặc action.
- Dễ Dàng Tái Cấu Trúc (Refactor): Khi bạn thay đổi cấu trúc trạng thái hoặc thêm/bỏ hành động, TypeScript sẽ chỉ ra tất cả các nơi cần cập nhật, giảm thiểu rủi ro phá vỡ ứng dụng.
- Kiểm Thử Dễ Dàng Hơn: Logic xử lý trạng thái được tập trung trong hàm reducer, vốn là một hàm "thuần khiết" (pure function). Việc kiểm thử các hàm thuần khiết này dễ hơn rất nhiều so với kiểm thử logic nằm rải rác trong các hàm
useState
vàuseEffect
. - Quản Lý Trạng Thái Phức Tạp Tốt Hơn: Đối với các ứng dụng có logic trạng thái phức tạp,
useReducer
cùng TypeScript cung cấp một mô hình có tổ chức và dự đoán được, giúp mở rộng và bảo trì ứng dụng dễ dàng hơn.
Sử dụng useReducer
có thể có một chút đường cong học hỏi ban đầu so với useState
, nhưng khi kết hợp với sự an toàn và rõ ràng của TypeScript, nó trở thành một công cụ cực kỳ mạnh mẽ để xây dựng các ứng dụng React chất lượng cao.
Comments