Bài 17.5: Bài tập thực hành form phức tạp với React-TypeScript

Bài 17.5: Bài tập thực hành form phức tạp với React-TypeScript
Chào mừng bạn đến với bài thực hành chuyên sâu về xây dựng form trong React, kết hợp với sức mạnh của TypeScript! Forms là một phần không thể thiếu trong hầu hết các ứng dụng web hiện đại. Từ việc đăng ký người dùng, tạo sản phẩm, đến cấu hình cài đặt - tất cả đều cần đến form. Tuy nhiên, khi form trở nên phức tạp với nhiều trường, dữ liệu lồng nhau, các trường phụ thuộc vào nhau, và yêu cầu xác thực (validation) chặt chẽ, việc quản lý chúng có thể trở thành một thách thức không nhỏ.
Trong bài này, chúng ta sẽ cùng nhau thực hành cách tiếp cận để xây dựng và quản lý những form "khó nhằn" đó một cách hiệu quả, tận dụng tối đa lợi ích mà React và TypeScript mang lại.
Tại sao Form Phức Tạp Lại Khó?
Trước khi bắt tay vào code, hãy điểm qua những lý do khiến form phức tạp đòi hỏi sự cẩn thận:
- Quản lý State: Form phức tạp thường có nhiều trường, đôi khi dữ liệu lồng nhau (như địa chỉ bao gồm đường, thành phố, zip code) hoặc danh sách các mục (như danh sách kỹ năng, sản phẩm trong đơn hàng). Việc lưu trữ và cập nhật state cho cấu trúc dữ liệu này cần phải được thực hiện một cách bất biến (immutability) trong React để đảm bảo render đúng và tránh lỗi.
- Validation (Xác thực): Các quy tắc xác thực có thể rất đa dạng và phức tạp. Một trường có thể yêu cầu định dạng cụ thể (email, số điện thoại), không được để trống, hoặc giá trị của nó phụ thuộc vào giá trị của trường khác. Hiển thị thông báo lỗi một cách rõ ràng và đúng vị trí cũng là một thách thức.
- Logic Điều Kiện: Nhiều form có các trường chỉ hiển thị dựa trên lựa chọn của người dùng (ví dụ: trường "Mã số sinh viên" chỉ hiện khi chọn "Tôi là sinh viên"). Việc quản lý trạng thái hiển thị này cần được tích hợp chặt chẽ với state của form.
- An toàn Kiểu Dữ liệu: Khi form có cấu trúc dữ liệu phức tạp, việc đảm bảo bạn đang truy cập đúng trường với đúng kiểu dữ liệu là rất quan trọng. Đây chính là lúc TypeScript tỏa sáng.
React và TypeScript Giải Quyết Thế Nào?
React: Quản lý State & Chia nhỏ Component
React cung cấp các hook như useState
và useReducer
để quản lý state của form. Với form đơn giản, useState
là đủ. Với form phức tạp hơn, useReducer
có thể giúp tập trung logic cập nhật state. Ngoài ra, React cho phép bạn chia form lớn thành các component nhỏ hơn (ví dụ: một component cho phần địa chỉ, một component cho danh sách kỹ năng), giúp code dễ đọc, dễ bảo lý và tái sử dụng.
TypeScript: An Toàn Kiểu Dữ liệu Tuyệt Đối
Đây là lợi ích đáng giá nhất khi làm việc với form phức tạp. Bằng cách định nghĩa rõ ràng kiểu dữ liệu (type) cho state của form, TypeScript sẽ:
- Giúp bạn chính xác biết cấu trúc dữ liệu trông như thế nào.
- Báo lỗi ngay lập tức nếu bạn cố gắng truy cập một trường không tồn tại hoặc gán sai kiểu dữ liệu.
- Hỗ trợ tự động hoàn thành code (autocompletion), tăng tốc độ code và giảm lỗi chính tả.
- Giúp code của bạn trở nên dễ hiểu hơn cho người khác (và cho chính bạn sau này).
Hãy bắt tay vào code với một ví dụ!
Thực Hành Xây Dựng Form Profile Phức Tạp
Chúng ta sẽ xây dựng một form đơn giản để nhập thông tin profile người dùng, bao gồm: Tên, Email, Địa chỉ (lồng nhau), Danh sách Kỹ năng (mảng), và tùy chọn thông tin Sinh viên.
Bước 1: Định Nghĩa Kiểu Dữ Liệu với TypeScript
Đầu tiên, hãy định nghĩa kiểu dữ liệu cho state của form. Điều này là bắt buộc và là điểm khởi đầu tốt khi làm việc với form phức tạp trong TypeScript.
// src/types/formTypes.ts
interface Address {
street: string;
city: string;
zipCode: string;
}
interface UserProfileFormState {
name: string;
email: string;
address: Address; // Dữ liệu lồng nhau
skills: string[]; // Mảng các kỹ năng
isStudent: boolean;
studentId?: string; // Trường tùy chọn, chỉ có khi isStudent là true
}
// Kiểu dữ liệu cho lỗi validation
interface FormErrors {
name?: string;
email?: string;
address?: { // Lỗi lồng nhau cho address
street?: string;
city?: string;
zipCode?: string;
};
skills?: string; // Hoặc string[] nếu muốn hiển thị lỗi cho từng skill
studentId?: string;
}
Giải thích:
Chúng ta định nghĩa hai interface: UserProfileFormState
mô tả cấu trúc dữ liệu chính xác của form, bao gồm cả đối tượng lồng nhau address
và mảng skills
. Trường studentId
được đánh dấu là tùy chọn (?
). Interface FormErrors
có cấu trúc tương tự để lưu trữ các thông báo lỗi cho từng trường. TypeScript sẽ sử dụng những định nghĩa này để kiểm tra code của chúng ta.
Bước 2: Khởi Tạo State và Sử Dụng useState
Trong component React của chúng ta, chúng ta sẽ sử dụng useState
để quản lý state của form, dựa trên kiểu dữ liệu đã định nghĩa.
```typescript jsx // src/components/UserProfileForm.tsx import React, { useState, FormEvent, ChangeEvent } from 'react'; import { UserProfileFormState, FormErrors } from '../types/formTypes'; // Import kiểu dữ liệu
const UserProfileForm: React.FC = () => { // Khởi tạo state với kiểu UserProfileFormState const [formData, setFormData] = useState<UserProfileFormState>({ name: '', email: '', address: { street: '', city: '', zipCode: '' }, skills: [], isStudent: false, // studentId không cần khởi tạo nếu là optional và không có giá trị mặc định });
const [errors, setErrors] = useState<FormErrors>({}); // State để lưu lỗi
// ... các hàm xử lý thay đổi input và submit sẽ ở đây ...
return ( <form onSubmit={handleSubmit}> {/<em> Các trường input sẽ được render ở đây </em>/} </form> ); };
export default UserProfileForm;
*Giải thích:*
Chúng ta khai báo state `formData` sử dụng hook `useState`, và quan trọng là chỉ định kiểu dữ liệu `<UserProfileFormState>`. Khi khởi tạo, chúng ta cung cấp một đối tượng có cấu trúc khớp với kiểu dữ liệu đó. State `errors` cũng được khởi tạo với kiểu `FormErrors`. Việc chỉ định kiểu giúp TypeScript đảm bảo rằng mọi thao tác đọc/ghi trên `formData` và `errors` đều hợp lệ về mặt kiểu.
### Bước 3: Xử Lý Thay Đổi Input
Đây là phần phức tạp nhất khi làm việc với state lồng nhau và mảng. Chúng ta cần viết các hàm xử lý sự kiện `onChange` cho từng loại input hoặc nhóm input.
#### Xử lý input đơn giản (name, email)
```typescript jsx
// Tiếp tục trong UserProfileForm.tsx
// Hàm xử lý thay đổi cho các input trực tiếp trong root state
const handleInputChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = event.target;
setFormData(prevState => ({
...prevState, // Giữ lại các trường khác của form data
[name]: value // Cập nhật trường cụ thể theo tên
}));
// Có thể xóa lỗi ngay khi người dùng gõ lại
if (errors[name as keyof FormErrors]) {
setErrors(prevErrors => ({
...prevErrors,
[name]: undefined // Xóa lỗi cho trường này
}));
}
};
Giải thích:
Hàm handleInputChange
sử dụng spread syntax (...
) để tạo một bản sao của state trước đó (prevState
), sau đó ghi đè lên trường có tên (name
) tương ứng với giá trị mới (value
). Việc này đảm bảo tính bất biến. TypeScript với [name]: value
hiểu rằng name
phải là một key hợp lệ của UserProfileFormState
và value
có kiểu tương thích (thường là string
). Chúng ta cũng thêm logic xóa lỗi đơn giản.
Xử lý input lồng nhau (address)
Với object lồng nhau như address
, chúng ta cần sao chép cả object cha lẫn object con khi cập nhật.
```typescript jsx // Tiếp tục trong UserProfileForm.tsx
// Hàm xử lý thay đổi cho các input trong object lồng nhau 'address' const handleAddressChange = (event: ChangeEvent<HTMLInputElement>) => { const { name, value } = event.target; setFormData(prevState => ({ ...prevState, // Sao chép root state address: { ...prevState.address, // Sao chép object 'address' [name]: value // Cập nhật trường cụ thể trong 'address' } })); // Xóa lỗi lồng nhau (phức tạp hơn, ví dụ đơn giản) if (errors.address?.[name as keyof FormErrors['address']]) { setErrors(prevErrors => ({ ...prevErrors, address: { ...prevErrors.address,
[name]: undefined
}
}));
}
};
*Giải thích:*
Ở đây, chúng ta cần hai cấp sao chép: `...prevState` cho state gốc và `...prevState.address` cho object `address`. Sau đó mới cập nhật trường cụ thể trong `address`. TypeScript đảm bảo rằng `prevState.address` là một object có các key như `street`, `city`, `zipCode`.
#### Xử lý checkbox (isStudent)
Checkbox cần xử lý giá trị `checked` thay vì `value`.
```typescript jsx
// Tiếp tục trong UserProfileForm.tsx
// Hàm xử lý thay đổi cho checkbox 'isStudent'
const handleIsStudentChange = (event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
setFormData(prevState => ({
...prevState,
isStudent: checked,
// Logic phụ thuộc: Nếu không phải sinh viên, xóa studentId (nếu có)
studentId: checked ? prevState.studentId : undefined // Hoặc '' tùy quy ước
}));
// Xóa lỗi nếu có liên quan đến studentId khi chuyển trạng thái
if (!checked && errors.studentId) {
setErrors(prevErrors => ({ ...prevErrors, studentId: undefined }));
}
};
Giải thích:
Khi isStudent
thay đổi, chúng ta cập nhật state isStudent
. Đồng thời, chúng ta thêm logic điều kiện: nếu bỏ chọn "Tôi là sinh viên", trường studentId
sẽ bị xóa (hoặc đặt về rỗng) để đảm bảo tính nhất quán của dữ liệu.
Xử lý mảng (skills)
Thêm/xóa các mục trong mảng đòi hỏi thao tác trên mảng.
```typescript jsx // Tiếp tục trong UserProfileForm.tsx
const [newSkill, setNewSkill] = useState(''); // State tạm cho input kỹ năng mới
const handleNewSkillChange = (event: ChangeEvent<HTMLInputElement>) => { setNewSkill(event.target.value); };
const addSkill = () => { if (newSkill.trim() && !formData.skills.includes(newSkill.trim())) { setFormData(prevState => ({ ...prevState, skills: [...prevState.skills, newSkill.trim()] // Thêm skill mới vào mảng (tạo mảng mới) })); setNewSkill(''); // Xóa nội dung input sau khi thêm // Có thể thêm logic xóa lỗi skills nếu cần } };
const removeSkill = (indexToRemove: number) => { setFormData(prevState => ({ ...prevState, skills: prevState.skills.filter((_, index) => index !== indexToRemove) // Lọc bỏ skill theo index (tạo mảng mới) })); // Có thể thêm logic xóa lỗi skills nếu cần };
*Giải thích:*
Để thêm một kỹ năng, chúng ta dùng `[...prevState.skills, newSkill]` để tạo *mảng mới* chứa các kỹ năng cũ cộng với kỹ năng mới. Để xóa, chúng ta dùng `filter` để tạo *mảng mới* chỉ chứa các kỹ năng không bị xóa. Quan trọng là luôn tạo mảng mới thay vì sửa trực tiếp mảng cũ (`prevState.skills.push()` hoặc `splice()` là sai trong React state updates).
### Bước 4: Xây Dựng Hàm Validation
Hàm validation sẽ nhận toàn bộ `formData` và trả về một object chứa các lỗi (nếu có). TypeScript rất hữu ích ở đây vì nó đảm bảo bạn đang kiểm tra các trường có thật và trả về lỗi có cấu trúc đúng.
```typescript
// Tiếp tục trong UserProfileForm.tsx
const validateForm = (data: UserProfileFormState): FormErrors => {
const errors: FormErrors = {}; // Khởi tạo object lỗi trống
// Validate Name
if (!data.name.trim()) {
errors.name = "Tên không được để trống.";
}
// Validate Email
if (!data.email.trim()) {
errors.email = "Email không được để trống.";
} else if (!/\S+@\S+\.\S+/.test(data.email)) { // Regex kiểm tra định dạng email đơn giản
errors.email = "Email không hợp lệ.";
}
// Validate Address (lồng nhau)
if (!data.address.street.trim()) {
errors.address = { ...errors.address, street: "Đường không được để trống." };
}
if (!data.address.city.trim()) {
errors.address = { ...errors.address, city: "Thành phố không được để trống." };
}
// Có thể thêm validation cho zipCode
// Validate StudentId (phụ thuộc vào isStudent)
if (data.isStudent && !data.studentId?.trim()) { // Sử dụng optional chaining ?.
errors.studentId = "Mã số sinh viên không được để trống khi bạn là sinh viên.";
}
// Validate Skills (ví dụ: cần ít nhất 1 kỹ năng)
if (data.skills.length === 0) {
errors.skills = "Vui lòng thêm ít nhất một kỹ năng.";
}
// ... thêm các quy tắc validation khác ...
return errors;
};
Giải thích:
Hàm validateForm
nhận formData
với kiểu UserProfileFormState
. Nó kiểm tra từng trường dựa trên logic nghiệp vụ và điền các thông báo lỗi vào object errors
có kiểu FormErrors
. TypeScript giúp bạn đảm bảo rằng bạn đang kiểm tra các thuộc tính đúng tên (data.name
, data.address.street
) và gán lỗi vào đúng vị trí (errors.name
, errors.address?.street
).
Bước 5: Xử Lý Submit Form
Khi form được submit, chúng ta sẽ gọi hàm validation, cập nhật state errors
và chỉ thực hiện hành động gửi dữ liệu nếu không có lỗi.
```typescript jsx // Tiếp tục trong UserProfileForm.tsx
const handleSubmit = (event: FormEvent) => { event.preventDefault(); // Ngăn chặn trình duyệt reload trang
const validationErrors = validateForm(formData); // Gọi hàm validation setErrors(validationErrors); // Cập nhật state lỗi
// Kiểm tra xem object lỗi có rỗng không const isValid = Object.keys(validationErrors).length === 0 && (!validationErrors.address || Object.keys(validationErrors.address).length === 0); // Kiểm tra cả lỗi lồng nhau
if (isValid) { console.log("Form hợp lệ, dữ liệu gửi đi:", formData); // Thực hiện gửi dữ liệu lên server hoặc xử lý tiếp // alert(JSON.stringify(formData, null, 2)); // Ví dụ hiển thị dữ liệu } else { console.log("Form có lỗi, không gửi đi.", validationErrors); // Có thể cuộn lên đầu form hoặc trường lỗi đầu tiên } };
*Giải thích:*
Hàm `handleSubmit` ngăn chặn hành vi submit mặc định của trình duyệt. Nó gọi `validateForm` và lưu kết quả vào state `errors`. Sau đó, nó kiểm tra xem `errors` có rỗng không (kiểm tra cả lỗi lồng nhau trong `address`). Nếu không có lỗi, dữ liệu `formData` (đã được kiểm tra kiểu và cấu trúc bởi TS) có thể được xử lý tiếp.
### Bước 6: Render Form và Hiển Thị Lỗi
Cuối cùng, chúng ta cần kết nối state và các hàm xử lý vào các phần tử JSX để hiển thị form và các thông báo lỗi.
```typescript jsx
// Tiếp tục trong UserProfileForm.tsx
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '15px', maxWidth: '400px', margin: 'auto' }}>
<h2>Cập nhật Profile</h2>
<div>
<label htmlFor="name">Tên:</label>
<input
type="text"
id="name"
name="name" // Tên phải khớp với key trong state/type
value={formData.name}
onChange={handleInputChange}
/>
{errors.name && <span style={{ color: 'red', fontSize: '0.8em' }}>{errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
/>
{errors.email && <span style={{ color: 'red', fontSize: '0.8em' }}>{errors.email}</span>}
</div>
{/* Phần Địa chỉ (lồng nhau) */}
<fieldset style={{ border: '1px solid #ccc', padding: '10px' }}>
<legend>Địa chỉ:</legend>
<div>
<label htmlFor="street">Đường:</label>
<input
type="text"
id="street"
name="street" // Tên khớp với key trong address object
value={formData.address.street}
onChange={handleAddressChange} // Sử dụng handler riêng cho address
/>
{errors.address?.street && <span style={{ color: 'red', fontSize: '0.8em' }}>{errors.address.street}</span>}
</div>
<div>
<label htmlFor="city">Thành phố:</label>
<input
type="text"
id="city"
name="city"
value={formData.address.city}
onChange={handleAddressChange}
/>
{errors.address?.city && <span style={{ color: 'red', fontSize: '0.8em' }}>{errors.address.city}</span>}
</div>
{/* Thêm input cho zipCode tương tự */}
</fieldset>
{/* Phần Kỹ năng (mảng) */}
<div>
<label>Kỹ năng:</label>
<ul>
{formData.skills.map((skill, index) => (
<li key={index}>
{skill}
<button type="button" onClick={() => removeSkill(index)} style={{ marginLeft: '5px', fontSize: '0.7em' }}>Xóa</button>
</li>
))}
</ul>
<div>
<input type="text" value={newSkill} onChange={handleNewSkillChange} placeholder="Thêm kỹ năng mới"/>
<button type="button" onClick={addSkill} style={{ marginLeft: '5px' }}>Thêm</button>
</div>
{errors.skills && <span style={{ color: 'red', fontSize: '0.8em' }}>{errors.skills}</span>}
</div>
{/* Phần Tùy chọn Sinh viên */}
<div>
<label>
<input
type="checkbox"
name="isStudent"
checked={formData.isStudent}
onChange={handleIsStudentChange} // Sử dụng handler riêng cho checkbox
/>
Tôi là sinh viên
</label>
</div>
{/* Trường studentId chỉ hiển thị khi isStudent là true */}
{formData.isStudent && (
<div>
<label htmlFor="studentId">Mã số sinh viên:</label>
<input
type="text"
id="studentId"
name="studentId"
value={formData.studentId || ''} // Sử dụng || '' để tránh undefined value cho input
onChange={handleInputChange} // Có thể dùng lại handleInputChange nếu tên khớp
/>
{errors.studentId && <span style={{ color: 'red', fontSize: '0.8em' }}>{errors.studentId}</span>}
</div>
)}
<button type="submit" style={{ marginTop: '20px', padding: '10px' }}>Lưu Profile</button>
</form>
);
// Export component
export default UserProfileForm;
Giải thích:
Mỗi input được kết nối với state formData
thông qua thuộc tính value
và với hàm xử lý thay đổi thông qua onChange
.
Quan trọng:
- Các input đơn giản dùng
handleInputChange
. - Các input trong
address
dùnghandleAddressChange
. - Checkbox
isStudent
dùnghandleIsStudentChange
. - Phần kỹ năng render mảng
formData.skills
và có input/button riêng để thêm/xóa, gọi các hàmaddSkill
/removeSkill
. - Trường
studentId
được hiển thị có điều kiện dựa vào giá trịformData.isStudent
. - Thông báo lỗi (
errors.fieldName
) được hiển thị ngay dưới input tương ứng, sử dụng cú pháp&&
để chỉ render khi có lỗi. Với lỗi lồng nhau nhưaddress?.street
, chúng ta dùng optional chaining (?.
) để truy cập an toàn.
Lợi Ích Của Việc Kết Hợp React và TypeScript
Qua ví dụ trên, bạn có thể thấy rõ những lợi ích khi xây dựng form phức tạp với React-TypeScript:
- Cấu trúc Rõ Ràng: Việc định nghĩa kiểu dữ liệu bằng TypeScript buộc bạn phải suy nghĩ rõ ràng về cấu trúc dữ liệu của form ngay từ đầu.
- Ngăn ngừa Lỗi Runtime: Hầu hết các lỗi liên quan đến sai tên trường, sai kiểu dữ liệu sẽ bị phát hiện ngay khi bạn code hoặc biên dịch, thay vì gây lỗi bất ngờ cho người dùng.
- Code Dễ Bảo Trì: Với kiểu dữ liệu rõ ràng và code được chia nhỏ (cho từng phần của form hoặc các input tùy chỉnh), việc sửa lỗi và thêm tính năng mới trở nên dễ dàng hơn nhiều.
- Phát Triển Hiệu Quả Hơn: Tự động hoàn thành code và gợi ý kiểu dữ liệu từ trình soạn thảo (IDE) giúp tăng tốc độ code đáng kể.
Khi Nào Nên Dùng Thư Viện Form?
Đối với các form rất phức tạp, hoặc khi bạn cần các tính năng nâng cao như:
- Validation dựa trên schema (ví dụ: Yup, Zod)
- Quản lý trạng thái "touched" (người dùng đã tương tác với trường này chưa?)
- Quản lý trạng thái "dirty" (dữ liệu đã thay đổi so với ban đầu chưa?)
- Tối ưu hiệu suất render khi form rất lớn
- Tích hợp dễ dàng với các UI library (Material UI, Ant Design, ...)
Bạn nên cân nhắc sử dụng các thư viện quản lý form phổ biến trong React như react-hook-form
hoặc Formik
. Các thư viện này đã xử lý sẵn rất nhiều logic phức tạp mà chúng ta vừa tự viết, giúp bạn tập trung hơn vào logic nghiệp vụ của form. Tuy nhiên, việc hiểu rõ cách form hoạt động ở mức cơ bản với useState
/useReducer
và TypeScript như bài thực hành này là nền tảng quan trọng để sử dụng các thư viện đó một cách hiệu quả.
Comments