Bài 17.2: Controlled components với React-TypeScript

Bài 17.2: Controlled components với React-TypeScript
Xử lý các input trong form là một công việc _không thể thiếu_ khi xây dựng bất kỳ ứng dụng web tương tác nào. Làm sao để lấy được dữ liệu người dùng nhập vào? Làm sao để hiển thị dữ liệu đó ngay lập tức khi họ gõ? Làm sao để thực hiện validation? Trong React, chúng ta có một cách tiếp cận mạnh mẽ và được khuyến khích để giải quyết những vấn đề này: sử dụng Controlled Components.
Và khi kết hợp với sức mạnh kiểm soát kiểu dữ liệu của TypeScript, việc làm việc với form càng trở nên an toàn và dễ quản lý hơn bao giờ hết!
Controlled Components là gì?
Trong HTML truyền thống, các phần tử form như <input>
, <textarea>
, <select>
thường tự quản lý trạng thái (state) của riêng chúng. Tức là, giá trị hiện tại của ô input nằm _trong_ DOM.
Với Controlled Components trong React, mọi thứ khác đi một chút. Thay vì để DOM "tự lo", chúng ta sẽ biến state của component React trở thành "nguồn sự thật duy nhất" (single source of truth) cho giá trị của phần tử form. Giá trị hiển thị trên UI luôn luôn được điều khiển bởi state của component.
Hãy hình dung thế này: Bạn có một biến state trong component. Giá trị của biến state này sẽ được gán vào thuộc tính value
của input. Bất cứ khi nào người dùng gõ chữ, thay vì input tự thay đổi giá trị nội tại, chúng ta sẽ bắt sự kiện onChange
và dùng nó để cập nhật state. Khi state được cập nhật, React sẽ re-render component, và input lại nhận giá trị mới từ state thông qua thuộc tính value
. Đó là một vòng lặp khép kín và hoàn toàn do React kiểm soát.
Cơ chế hoạt động: value
và onChange
Bí quyết của Controlled Components nằm ở việc sử dụng hai thuộc tính chính trên các phần tử form:
value
: Thuộc tính này nhận giá trị từ state của component. Nó buộc phần tử form hiển thị chính xác giá trị mà state đang giữ.onChange
: Thuộc tính này là một event handler (hàm xử lý sự kiện) được gọi mỗi khi giá trị của phần tử form thay đổi (ví dụ: người dùng gõ một ký tự, chọn một option trong select). Bên trong handler này, chúng ta sẽ lấy giá trị mới từ sự kiện và dùng hàm cập nhật state (setState
hoặc từuseState
) để thay đổi state của component.
Đây chính là vòng lặp thần kỳ: State -> value
-> Thay đổi input -> onChange
-> Cập nhật State -> re-render -> State mới -> value
mới...
Bắt đầu với TypeScript: Input đơn giản
Kết hợp TypeScript vào đây giúp chúng ta định nghĩa rõ ràng kiểu dữ liệu cho state và cho các event handler. Hãy xem ví dụ về một ô input đơn giản để nhập tên:
import React, { useState, ChangeEvent } from 'react';
function NameInput() {
// 1. Định nghĩa state với kiểu string
const [name, setName] = useState<string>('');
// 2. Định nghĩa hàm xử lý sự kiện onChange với kiểu ChangeEvent<HTMLInputElement>
const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {
// Lấy giá trị mới từ input và cập nhật state
setName(event.target.value);
};
return (
<div>
<label htmlFor="name">Nhập tên của bạn:</label>
<input
type="text"
id="name"
value={name} // 3. Gán giá trị state vào thuộc tính 'value'
onChange={handleNameChange} // 4. Gắn hàm xử lý sự kiện vào thuộc tính 'onChange'
/>
{/* Hiển thị giá trị hiện tại của state ngay lập tức */}
<p>Tên bạn đã nhập: **{name}**</p>
</div>
);
}
export default NameInput;
Giải thích:
useState<string>('')
: Chúng ta khai báo một statename
với giá trị ban đầu là chuỗi rỗng (''
). TypeScript biếtname
sẽ luôn là kiểustring
.ChangeEvent<HTMLInputElement>
: Đây là kiểu dữ liệu mà TypeScript cung cấp cho sự kiệnchange
trên các phần tử<input>
. Nó đảm bảo rằng trong hàmhandleNameChange
, đối tượngevent
sẽ có đầy đủ các thuộc tính cần thiết của một sự kiện trên input, bao gồmevent.target.value
.event.target.value
: Thuộc tính này chứa giá trị mới nhất của ô input sau khi sự kiệnchange
xảy ra.setName(event.target.value)
: Chúng ta gọi hàm cập nhật state để thay đổi giá trị củaname
.value={name}
: Thuộc tínhvalue
của thẻ<input>
luôn nhận giá trị từ statename
. Điều này đảm bảo input luôn hiển thị đúng giá trị mà state đang giữ.onChange={handleNameChange}
: Mỗi khi người dùng gõ phím, hàmhandleNameChange
sẽ được gọi.
Bạn có thể thấy dòng <p>Tên bạn đã nhập: **{name}**</p>
sẽ cập nhật nội dung ngay lập tức khi bạn gõ. Đó là sức mạnh của việc state là nguồn sự thật!
Áp dụng cho các loại phần tử form khác
Cơ chế value
và onChange
tương tự cũng được áp dụng cho <textarea>
và <select>
, chỉ khác một chút về kiểu dữ liệu của sự kiện onChange
.
<textarea>
: Hoạt động y hệt<input text>
. Bạn sử dụng thuộc tínhvalue
vàonChange
. Kiểu sự kiện làChangeEvent<HTMLTextAreaElement>
.import React, { useState, ChangeEvent } from 'react'; function DescriptionInput() { const [description, setDescription] = useState<string>(''); const handleDescriptionChange = (event: ChangeEvent<HTMLTextAreaElement>) => { setDescription(event.target.value); }; return ( <div> <label htmlFor="description">Mô tả:</label> <textarea id="description" value={description} onChange={handleDescriptionChange} /> <p>Mô tả của bạn: *{description}*</p> </div> ); }
<select>
: Cũng sử dụng thuộc tínhvalue
vàonChange
. Giá trị của<select>
controlled component là thuộc tínhvalue
trên thẻ<select>
chính (không phải trên các thẻ<option>
). Để lấy giá trị option được chọn, bạn vẫn truy cậpevent.target.value
. Kiểu sự kiện làChangeEvent<HTMLSelectElement>
.import React, { useState, ChangeEvent } from 'react'; function SelectColor() { const [selectedColor, setSelectedColor] = useState<string>('red'); // Giá trị ban đầu const handleColorChange = (event: ChangeEvent<HTMLSelectElement>) => { setSelectedColor(event.target.value); }; return ( <div> <label htmlFor="color">Chọn màu:</label> <select id="color" value={selectedColor} onChange={handleColorChange}> <option value="red">Đỏ</option> <option value="blue">Xanh Dương</option> <option value="green">Xanh Lá</option> </select> <p>Màu bạn chọn: **{selectedColor}**</p> </div> ); }
Lưu ý: Với
<select multiple>
, giá trị củaevent.target.value
sẽ là một mảng các chuỗi.
Xử lý nhiều Inputs với một State Object
Trong các form thực tế, bạn thường có nhiều hơn một ô input. Việc tạo riêng từng useState
cho mỗi input có thể dẫn đến boilerplate code khá nhiều. Một cách tiếp cận thông minh và gọn gàng hơn là sử dụng một object làm state để chứa tất cả các giá trị của form, và viết một hàm xử lý sự kiện onChange
chung cho tất cả các input.
Để làm được điều này, mỗi thẻ <input>
hoặc <textarea>
cần có thêm thuộc tính name
để hàm xử lý chung biết được field nào đang thay đổi.
import React, { useState, ChangeEvent, FormEvent } from 'react';
// Định nghĩa kiểu dữ liệu cho state object
interface FormData {
firstName: string;
lastName: string;
email: string;
description: string; // Ví dụ thêm textarea
}
function MultiInputForm() {
// State là một object chứa tất cả giá trị form
const [formData, setFormData] = useState<FormData>({
firstName: '',
lastName: '',
email: '',
description: '',
});
// Handler chung cho cả input và textarea
// Sử dụng union type cho ChangeEvent
const handleInputChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = event.target; // Lấy thuộc tính 'name' và 'value' từ phần tử gây ra sự kiện
// Cập nhật state object, giữ lại các field khác
setFormData(prevState => ({
...prevState, // Copy tất cả các thuộc tính hiện có
[name]: value, // Cập nhật thuộc tính có tên trùng với 'name' của input
}));
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); // Ngăn hành vi submit form mặc định (reload trang)
console.log('Dữ liệu form:', formData);
// Tại đây bạn có thể xử lý dữ liệu formData, ví dụ gửi lên API
// reset form nếu cần: setFormData({ firstName: '', lastName: '', email: '', description: '' });
};
return (
<form onSubmit={handleSubmit}> {/* Gắn handler cho sự kiện submit */}
<div>
<label htmlFor="firstName">Tên:</label>
<input
type="text"
id="firstName"
name="firstName" // **Quan trọng:** Thêm thuộc tính 'name'
value={formData.firstName} // Gán giá trị từ state object
onChange={handleInputChange} // Sử dụng handler chung
/>
</div>
<div>
<label htmlFor="lastName">Họ:</label>
<input
type="text"
id="lastName"
name="lastName" // **Quan trọng:** Thêm thuộc tính 'name'
value={formData.lastName}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email" // **Quan trọng:** Thêm thuộc tính 'name'
value={formData.email}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="description">Mô tả chi tiết:</label>
<textarea
id="description"
name="description" // **Quan trọng:** Thêm thuộc tính 'name'
value={formData.description}
onChange={handleInputChange}
/>
</div>
<button type="submit">Gửi Form</button>
</form>
);
}
export default MultiInputForm;
Giải thích:
interface FormData
: Chúng ta định nghĩa kiểu cho object state để TypeScript biết cấu trúc dữ liệu.useState<FormData>({...})
: StateformData
bây giờ là một object tuân theo kiểuFormData
.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
: Kiểu này cho phép handlerhandleInputChange
nhận sự kiện từ cả<input>
và<textarea>
.const { name, value } = event.target;
: Đây là kỹ thuật destructuring để lấy ra thuộc tínhname
(tên của input/textarea, ví dụ: "firstName", "email") vàvalue
(giá trị hiện tại của input/textarea) từ phần tử DOM gây ra sự kiện.setFormData(prevState => ({ ...prevState, [name]: value, }));
: Đây là phần mấu chốt. Chúng ta sử dụng functional update của state (prevState => ...
) để đảm bảo luôn làm việc với state gần nhất.{ ...prevState }
tạo một bản sao (shallow copy) của state object trước đó.[name]: value
là cú pháp computed property name của JavaScript, nó sẽ thêm hoặc cập nhật thuộc tính có tên bằng giá trị của biếnname
(ví dụ: nếuname
là "email", nó sẽ cập nhật thuộc tínhemail
).name="firstName"
(trên các thẻ input/textarea): Thuộc tính này là bắt buộc để hàm xử lý chunghandleInputChange
có thể biết được thuộc tính nào trong state object cần được cập nhật.onSubmit={handleSubmit}
trên thẻ<form>
: Gắn hàm xử lý sự kiện submit form.event.preventDefault()
: Quan trọng! Nó ngăn trình duyệt thực hiện hành vi submit form mặc định (thường là tải lại trang), cho phép React kiểm soát việc submit.
Với cách này, bạn có thể dễ dàng thêm bao nhiêu input tùy ý vào form mà chỉ cần sửa đổi interface FormData
và thêm input tương ứng vào JSX, chỉ cần đảm bảo thuộc tính name
khớp với tên thuộc tính trong state object.
Ưu điểm của Controlled Components
Việc sử dụng Controlled Components mang lại rất nhiều lợi ích:
- State luôn được đồng bộ: Giá trị trong state React luôn phản ánh chính xác những gì người dùng nhìn thấy và nhập vào trên UI.
- Truy cập giá trị dễ dàng: Bạn luôn có thể truy cập giá trị hiện tại của form thông qua state bất cứ lúc nào.
- Validation theo thời gian thực: Rất dễ dàng để kiểm tra giá trị nhập vào và hiển thị lỗi ngay khi người dùng gõ.
- Chuyển đổi/Định dạng dữ liệu: Bạn có thể xử lý (ví dụ: chuyển sang chữ in hoa, loại bỏ ký tự đặc biệt) giá trị ngay trong handler
onChange
trước khi cập nhật state. - Điều khiển trạng thái UI: Dễ dàng bật/tắt (enable/disable) các phần tử khác trong form hoặc trên trang dựa trên giá trị của input (ví dụ: disable nút "Gửi" cho đến khi email hợp lệ).
- Debugging đơn giản hơn: Vì state là nguồn sự thật, việc theo dõi luồng dữ liệu và tìm lỗi trở nên dễ dàng hơn nhiều.
- An toàn với TypeScript: TypeScript giúp bạn đảm bảo rằng bạn đang xử lý đúng kiểu dữ liệu cho state và sự kiện, giảm thiểu lỗi runtime.
Comments