Có Thể Bạn Không Cần Effect
Effect là một lối thoát khỏi mô hình React. Chúng cho phép bạn “bước ra ngoài” React và đồng bộ hóa các component của bạn với một số hệ thống bên ngoài như một widget không phải React, mạng hoặc DOM của trình duyệt. Nếu không có hệ thống bên ngoài nào liên quan (ví dụ: nếu bạn muốn cập nhật state của một component khi một số prop hoặc state thay đổi), bạn không nên cần đến Effect. Loại bỏ các Effect không cần thiết sẽ giúp code của bạn dễ theo dõi hơn, chạy nhanh hơn và ít bị lỗi hơn.
Bạn sẽ được học
- Tại sao và làm thế nào để loại bỏ các Effect không cần thiết khỏi component của bạn
- Cách lưu trữ các phép tính tốn kém mà không cần Effect
- Cách đặt lại và điều chỉnh state của component mà không cần Effect
- Cách chia sẻ logic giữa các trình xử lý sự kiện
- Logic nào nên được chuyển sang trình xử lý sự kiện
- Cách thông báo cho các component cha về các thay đổi
Làm thế nào để loại bỏ các Effect không cần thiết
Có hai trường hợp phổ biến mà bạn không cần Effect:
- Bạn không cần Effect để chuyển đổi dữ liệu để hiển thị. Ví dụ: giả sử bạn muốn lọc một danh sách trước khi hiển thị nó. Bạn có thể cảm thấy muốn viết một Effect để cập nhật một biến state khi danh sách thay đổi. Tuy nhiên, điều này không hiệu quả. Khi bạn cập nhật state, React sẽ gọi các hàm component của bạn để tính toán những gì sẽ hiển thị trên màn hình. Sau đó, React sẽ “commit” những thay đổi này vào DOM, cập nhật màn hình. Sau đó, React sẽ chạy các Effect của bạn. Nếu Effect của bạn cũng ngay lập tức cập nhật state, điều này sẽ khởi động lại toàn bộ quá trình từ đầu! Để tránh các lần render không cần thiết, hãy chuyển đổi tất cả dữ liệu ở cấp cao nhất của component của bạn. Code đó sẽ tự động chạy lại bất cứ khi nào prop hoặc state của bạn thay đổi.
- Bạn không cần Effect để xử lý các sự kiện của người dùng. Ví dụ: giả sử bạn muốn gửi một yêu cầu POST
/api/buy
và hiển thị một thông báo khi người dùng mua một sản phẩm. Trong trình xử lý sự kiện click của nút Mua, bạn biết chính xác những gì đã xảy ra. Vào thời điểm Effect chạy, bạn không biết người dùng đã làm gì (ví dụ: nút nào đã được click). Đây là lý do tại sao bạn thường xử lý các sự kiện của người dùng trong các trình xử lý sự kiện tương ứng.
Bạn cần Effect để đồng bộ hóa với các hệ thống bên ngoài. Ví dụ: bạn có thể viết một Effect để giữ cho một widget jQuery được đồng bộ hóa với state của React. Bạn cũng có thể tìm nạp dữ liệu bằng Effect: ví dụ: bạn có thể đồng bộ hóa kết quả tìm kiếm với truy vấn tìm kiếm hiện tại. Hãy nhớ rằng các framework hiện đại cung cấp các cơ chế tìm nạp dữ liệu tích hợp hiệu quả hơn so với việc viết Effect trực tiếp trong component của bạn.
Để giúp bạn có được trực giác đúng đắn, hãy xem một số ví dụ cụ thể phổ biến!
Cập nhật state dựa trên prop hoặc state
Giả sử bạn có một component với hai biến state: firstName
và lastName
. Bạn muốn tính toán một fullName
từ chúng bằng cách nối chúng lại với nhau. Hơn nữa, bạn muốn fullName
cập nhật bất cứ khi nào firstName
hoặc lastName
thay đổi. Bản năng đầu tiên của bạn có thể là thêm một biến state fullName
và cập nhật nó trong một Effect:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 Tránh: state dư thừa và Effect không cần thiết
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
Điều này phức tạp hơn mức cần thiết. Nó cũng không hiệu quả: nó thực hiện một lần render hoàn chỉnh với một giá trị cũ cho fullName
, sau đó ngay lập tức render lại với giá trị đã cập nhật. Loại bỏ biến state và Effect:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Tốt: được tính toán trong quá trình render
const fullName = firstName + ' ' + lastName;
// ...
}
Khi một cái gì đó có thể được tính toán từ các prop hoặc state hiện có, đừng đưa nó vào state. Thay vào đó, hãy tính toán nó trong quá trình render. Điều này làm cho code của bạn nhanh hơn (bạn tránh được các cập nhật “xếp tầng” bổ sung), đơn giản hơn (bạn loại bỏ một số code) và ít bị lỗi hơn (bạn tránh được các lỗi do các biến state khác nhau bị lệch pha với nhau). Nếu cách tiếp cận này có vẻ mới đối với bạn, Thinking in React giải thích những gì nên đưa vào state.
Lưu trữ các phép tính tốn kém
Component này tính toán visibleTodos
bằng cách lấy todos
mà nó nhận được bằng prop và lọc chúng theo prop filter
. Bạn có thể cảm thấy muốn lưu trữ kết quả trong state và cập nhật nó từ một Effect:
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 Tránh: state dư thừa và Effect không cần thiết
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
Giống như trong ví dụ trước, điều này vừa không cần thiết vừa không hiệu quả. Đầu tiên, loại bỏ state và Effect:
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ Điều này ổn nếu getFilteredTodos() không chậm.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
Thông thường, đoạn code này vẫn ổn! Nhưng có thể getFilteredTodos()
chạy chậm hoặc bạn có rất nhiều todos
. Trong trường hợp đó, bạn không muốn tính toán lại getFilteredTodos()
nếu một biến state không liên quan như newTodo
đã thay đổi.
Bạn có thể lưu vào bộ nhớ cache (hoặc “ghi nhớ”) một phép tính tốn kém bằng cách bọc nó trong một Hook useMemo
:
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ✅ Không chạy lại trừ khi todos hoặc filter thay đổi
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}
Hoặc, viết dưới dạng một dòng duy nhất:
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ Không chạy lại getFilteredTodos() trừ khi todos hoặc filter thay đổi
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
Điều này cho React biết rằng bạn không muốn hàm bên trong chạy lại trừ khi todos
hoặc filter
đã thay đổi. React sẽ ghi nhớ giá trị trả về của getFilteredTodos()
trong quá trình render ban đầu. Trong quá trình render tiếp theo, nó sẽ kiểm tra xem todos
hoặc filter
có khác nhau hay không. Nếu chúng giống như lần trước, useMemo
sẽ trả về kết quả cuối cùng mà nó đã lưu trữ. Nhưng nếu chúng khác nhau, React sẽ gọi lại hàm bên trong (và lưu trữ kết quả của nó).
Hàm bạn bọc trong useMemo
chạy trong quá trình render, vì vậy điều này chỉ hoạt động đối với các phép tính thuần túy.
Tìm hiểu sâu
Nói chung, trừ khi bạn đang tạo hoặc lặp qua hàng nghìn đối tượng, có lẽ nó không tốn kém. Nếu bạn muốn tự tin hơn, bạn có thể thêm một bản ghi console để đo thời gian dành cho một đoạn code:
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
Thực hiện tương tác bạn đang đo (ví dụ: nhập vào đầu vào). Sau đó, bạn sẽ thấy các bản ghi như filter array: 0.15ms
trong bảng điều khiển của mình. Nếu tổng thời gian được ghi lại cộng lại thành một lượng đáng kể (ví dụ: 1ms
trở lên), thì có thể có ý nghĩa khi ghi nhớ phép tính đó. Như một thử nghiệm, sau đó bạn có thể bọc phép tính trong useMemo
để xác minh xem tổng thời gian được ghi lại có giảm cho tương tác đó hay không:
console.time('filter array');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // Bỏ qua nếu todos và filter không thay đổi
}, [todos, filter]);
console.timeEnd('filter array');
useMemo
sẽ không làm cho quá trình render đầu tiên nhanh hơn. Nó chỉ giúp bạn bỏ qua các công việc không cần thiết khi cập nhật.
Hãy nhớ rằng máy của bạn có thể nhanh hơn máy của người dùng, vì vậy bạn nên kiểm tra hiệu suất với một sự chậm lại nhân tạo. Ví dụ: Chrome cung cấp tùy chọn Điều chỉnh CPU cho việc này.
Cũng lưu ý rằng việc đo hiệu suất trong quá trình phát triển sẽ không cung cấp cho bạn kết quả chính xác nhất. (Ví dụ: khi Chế độ nghiêm ngặt được bật, bạn sẽ thấy mỗi thành phần render hai lần thay vì một lần.) Để có được thời gian chính xác nhất, hãy xây dựng ứng dụng của bạn để sản xuất và kiểm tra nó trên một thiết bị như người dùng của bạn có.
Đặt lại tất cả trạng thái khi một prop thay đổi
Thành phần ProfilePage
này nhận một prop userId
. Trang này chứa một đầu vào nhận xét và bạn sử dụng một biến state comment
để giữ giá trị của nó. Một ngày nọ, bạn nhận thấy một vấn đề: khi bạn điều hướng từ hồ sơ này sang hồ sơ khác, trạng thái comment
không được đặt lại. Do đó, rất dễ vô tình đăng nhận xét trên hồ sơ của người dùng sai. Để khắc phục sự cố, bạn muốn xóa biến state comment
bất cứ khi nào userId
thay đổi:
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 Tránh: Đặt lại trạng thái khi thay đổi prop trong một Effect
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
Điều này không hiệu quả vì ProfilePage
và các thành phần con của nó sẽ render trước với giá trị cũ, sau đó render lại. Nó cũng phức tạp vì bạn cần phải làm điều này trong mọi thành phần có một số state bên trong ProfilePage
. Ví dụ: nếu giao diện người dùng nhận xét được lồng nhau, bạn cũng muốn xóa state nhận xét lồng nhau.
Thay vào đó, bạn có thể cho React biết rằng hồ sơ của mỗi người dùng về mặt khái niệm là một hồ sơ khác nhau bằng cách cung cấp cho nó một khóa rõ ràng. Chia component của bạn thành hai và chuyển một thuộc tính key
từ component bên ngoài sang component bên trong:
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ Trạng thái này và bất kỳ trạng thái nào khác bên dưới sẽ tự động đặt lại khi thay đổi khóa
const [comment, setComment] = useState('');
// ...
}
Thông thường, React giữ nguyên state khi cùng một component được render ở cùng một vị trí. Bằng cách chuyển userId
làm key
cho component Profile
, bạn đang yêu cầu React coi hai component Profile
có userId
khác nhau là hai component khác nhau không được chia sẻ bất kỳ state nào. Bất cứ khi nào khóa (mà bạn đã đặt thành userId
) thay đổi, React sẽ tạo lại DOM và đặt lại state của component Profile
và tất cả các component con của nó. Bây giờ trường comment
sẽ tự động xóa khi điều hướng giữa các hồ sơ.
Lưu ý rằng trong ví dụ này, chỉ component ProfilePage
bên ngoài được xuất và hiển thị cho các tệp khác trong dự án. Các component render ProfilePage
không cần phải chuyển khóa cho nó: chúng chuyển userId
làm một prop thông thường. Việc ProfilePage
chuyển nó làm key
cho component Profile
bên trong là một chi tiết triển khai.
Điều chỉnh một số trạng thái khi một prop thay đổi
Đôi khi, bạn có thể muốn đặt lại hoặc điều chỉnh một phần của state khi một prop thay đổi, nhưng không phải tất cả.
Component List
này nhận một danh sách items
làm một prop và duy trì mục đã chọn trong biến state selection
. Bạn muốn đặt lại selection
thành null
bất cứ khi nào
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 Tránh: Điều chỉnh trạng thái khi thay đổi prop trong một Effect
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
Điều này cũng không lý tưởng. Mỗi khi items
thay đổi, List
và các thành phần con của nó sẽ render với giá trị selection
cũ trước. Sau đó, React sẽ cập nhật DOM và chạy các Effect. Cuối cùng, lệnh gọi setSelection(null)
sẽ gây ra một lần render lại List
và các thành phần con của nó, khởi động lại toàn bộ quá trình này.
Bắt đầu bằng cách xóa Effect. Thay vào đó, hãy điều chỉnh trạng thái trực tiếp trong quá trình render:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Tốt hơn: Điều chỉnh trạng thái trong khi render
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
Lưu trữ thông tin từ các lần render trước như thế này có thể khó hiểu, nhưng nó tốt hơn là cập nhật cùng một trạng thái trong một Effect. Trong ví dụ trên, setSelection
được gọi trực tiếp trong quá trình render. React sẽ render lại List
ngay lập tức sau khi nó thoát bằng một câu lệnh return
. React chưa render các thành phần con List
hoặc cập nhật DOM, vì vậy điều này cho phép các thành phần con List
bỏ qua việc render giá trị selection
cũ.
Khi bạn cập nhật một thành phần trong quá trình render, React sẽ loại bỏ JSX được trả về và thử lại render ngay lập tức. Để tránh các lần thử lại xếp tầng rất chậm, React chỉ cho phép bạn cập nhật trạng thái của cùng một thành phần trong quá trình render. Nếu bạn cập nhật trạng thái của một thành phần khác trong quá trình render, bạn sẽ thấy lỗi. Một điều kiện như items !== prevItems
là cần thiết để tránh các vòng lặp. Bạn có thể điều chỉnh trạng thái như thế này, nhưng bất kỳ tác dụng phụ nào khác (như thay đổi DOM hoặc đặt thời gian chờ) nên ở trong các trình xử lý sự kiện hoặc Effect để giữ cho các thành phần thuần túy.
Mặc dù mẫu này hiệu quả hơn một Effect, nhưng hầu hết các thành phần cũng không cần nó. Bất kể bạn làm điều đó như thế nào, việc điều chỉnh trạng thái dựa trên các prop hoặc trạng thái khác sẽ làm cho luồng dữ liệu của bạn khó hiểu và gỡ lỗi hơn. Luôn kiểm tra xem bạn có thể đặt lại tất cả trạng thái bằng một khóa hoặc tính toán mọi thứ trong quá trình render hay không. Ví dụ: thay vì lưu trữ (và đặt lại) mục đã chọn, bạn có thể lưu trữ ID mục đã chọn:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Tốt nhất: Tính toán mọi thứ trong quá trình render
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
Bây giờ không cần phải “điều chỉnh” trạng thái nữa. Nếu mục có ID đã chọn nằm trong danh sách, nó vẫn được chọn. Nếu không, selection
được tính toán trong quá trình render sẽ là null
vì không tìm thấy mục phù hợp. Hành vi này khác, nhưng có thể tốt hơn vì hầu hết các thay đổi đối với items
đều giữ nguyên lựa chọn.
Chia sẻ logic giữa các trình xử lý sự kiện
Giả sử bạn có một trang sản phẩm với hai nút (Mua và Thanh toán) cho phép bạn mua sản phẩm đó. Bạn muốn hiển thị thông báo bất cứ khi nào người dùng đặt sản phẩm vào giỏ hàng. Gọi showNotification()
trong cả hai trình xử lý nhấp của nút có vẻ lặp đi lặp lại, vì vậy bạn có thể muốn đặt logic này trong một Effect:
function ProductPage({ product, addToCart }) {
// 🔴 Tránh: Logic dành riêng cho sự kiện bên trong một Effect
useEffect(() => {
if (product.isInCart) {
showNotification(`Đã thêm ${product.name} vào giỏ hàng!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
Effect này là không cần thiết. Nó cũng rất có thể gây ra lỗi. Ví dụ: giả sử ứng dụng của bạn “ghi nhớ” giỏ hàng giữa các lần tải lại trang. Nếu bạn thêm một sản phẩm vào giỏ hàng một lần và làm mới trang, thông báo sẽ xuất hiện lại. Nó sẽ tiếp tục xuất hiện mỗi khi bạn làm mới trang sản phẩm đó. Điều này là do product.isInCart
sẽ đã là true
khi tải trang, vì vậy Effect trên sẽ gọi showNotification()
.
Khi bạn không chắc chắn liệu một số mã nên nằm trong một Effect hay trong một trình xử lý sự kiện, hãy tự hỏi tại sao mã này cần chạy. Chỉ sử dụng Effect cho mã nên chạy vì thành phần đã được hiển thị cho người dùng. Trong ví dụ này, thông báo sẽ xuất hiện vì người dùng nhấn nút, không phải vì trang đã được hiển thị! Xóa Effect và đặt logic được chia sẻ vào một hàm được gọi từ cả hai trình xử lý sự kiện:
function ProductPage({ product, addToCart }) {
// ✅ Tốt: Logic dành riêng cho sự kiện được gọi từ các trình xử lý sự kiện
function buyProduct() {
addToCart(product);
showNotification(`Đã thêm ${product.name} vào giỏ hàng!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
Điều này vừa loại bỏ Effect không cần thiết vừa sửa lỗi.
Gửi một yêu cầu POST
Thành phần Form
này gửi hai loại yêu cầu POST. Nó gửi một sự kiện phân tích khi nó được gắn kết. Khi bạn điền vào biểu mẫu và nhấp vào nút Gửi, nó sẽ gửi một yêu cầu POST đến điểm cuối /api/register
:
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Tốt: Logic này sẽ chạy vì thành phần đã được hiển thị
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// 🔴 Tránh: Logic dành riêng cho sự kiện bên trong một Effect
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
Hãy áp dụng các tiêu chí tương tự như trong ví dụ trước.
Yêu cầu POST phân tích nên vẫn còn trong một Effect. Điều này là do lý do để gửi sự kiện phân tích là biểu mẫu đã được hiển thị. (Nó sẽ kích hoạt hai lần trong quá trình phát triển, nhưng xem tại đây để biết cách xử lý điều đó.)
Tuy nhiên, yêu cầu POST /api/register
không phải do biểu mẫu được hiển thị. Bạn chỉ muốn gửi yêu cầu vào một thời điểm cụ thể: khi người dùng nhấn nút. Nó sẽ chỉ xảy ra trong tương tác cụ thể đó. Xóa Effect thứ hai và di chuyển yêu cầu POST đó vào trình xử lý sự kiện:
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Tốt: Logic này chạy vì thành phần đã được hiển thị
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// ✅ Tốt: Logic dành riêng cho sự kiện nằm trong trình xử lý sự kiện
post('/api/register', { firstName, lastName });
}
// ...
}
Khi bạn chọn có nên đặt một số logic vào một trình xử lý sự kiện hay một Effect, câu hỏi chính bạn cần trả lời là loại logic đó là gì từ quan điểm của người dùng. Nếu logic này được gây ra bởi một tương tác cụ thể, hãy giữ nó trong trình xử lý sự kiện. Nếu nó được gây ra bởi người dùng nhìn thấy thành phần trên màn hình, hãy giữ nó trong Effect.
Chuỗi các phép tính
Đôi khi bạn có thể cảm thấy muốn xâu chuỗi các Effect mà mỗi Effect điều chỉnh một phần của trạng thái dựa trên trạng thái khác:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 Tránh: Chuỗi các Effect điều chỉnh trạng thái chỉ để kích hoạt lẫn nhau
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
// ...
Có hai vấn đề với đoạn code này.
Vấn đề đầu tiên là nó rất kém hiệu quả: thành phần (và các thành phần con của nó) phải render lại giữa mỗi lệnh gọi set
trong chuỗi. Trong ví dụ trên, trong trường hợp xấu nhất (setCard
→ render → setGoldCardCount
→ render → setRound
→ render → setIsGameOver
→ render) có ba lần render lại cây không cần thiết bên dưới.
Vấn đề thứ hai là ngay cả khi nó không chậm, khi code của bạn phát triển, bạn sẽ gặp phải các trường hợp mà “chuỗi” bạn đã viết không phù hợp với các yêu cầu mới. Hãy tưởng tượng bạn đang thêm một cách để xem qua lịch sử các bước di chuyển của trò chơi. Bạn sẽ làm điều đó bằng cách cập nhật từng biến trạng thái thành một giá trị từ quá khứ. Tuy nhiên, việc đặt trạng thái card
thành một giá trị từ quá khứ sẽ kích hoạt lại chuỗi Effect và thay đổi dữ liệu bạn đang hiển thị. Code như vậy thường cứng nhắc và dễ vỡ.
Trong trường hợp này, tốt hơn là tính toán những gì bạn có thể trong quá trình render và điều chỉnh trạng thái trong trình xử lý sự kiện:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ Tính toán những gì bạn có thể trong quá trình render
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ Tính toán tất cả trạng thái tiếp theo trong trình xử lý sự kiện
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
// ...
Điều này hiệu quả hơn rất nhiều. Ngoài ra, nếu bạn triển khai một cách để xem lịch sử trò chơi, giờ đây bạn sẽ có thể đặt từng biến trạng thái thành một bước di chuyển từ quá khứ mà không kích hoạt chuỗi Effect điều chỉnh mọi giá trị khác. Nếu bạn cần sử dụng lại logic giữa một số trình xử lý sự kiện, bạn có thể trích xuất một hàm và gọi nó từ các trình xử lý đó.
Hãy nhớ rằng bên trong các trình xử lý sự kiện, trạng thái hoạt động như một ảnh chụp nhanh. Ví dụ: ngay cả sau khi bạn gọi setRound(round + 1)
, biến round
sẽ phản ánh giá trị tại thời điểm người dùng nhấp vào nút. Nếu bạn cần sử dụng giá trị tiếp theo cho các phép tính, hãy xác định nó theo cách thủ công như const nextRound = round + 1
.
Trong một số trường hợp, bạn không thể tính toán trạng thái tiếp theo trực tiếp trong trình xử lý sự kiện. Ví dụ: hãy tưởng tượng một biểu mẫu có nhiều danh sách thả xuống, trong đó các tùy chọn của danh sách thả xuống tiếp theo phụ thuộc vào giá trị đã chọn của danh sách thả xuống trước đó. Sau đó, một chuỗi các Effect là phù hợp vì bạn đang đồng bộ hóa với mạng.
Khởi tạo ứng dụng
Một số logic chỉ nên chạy một lần khi ứng dụng tải.
Bạn có thể muốn đặt nó trong một Effect trong thành phần cấp cao nhất:
function App() {
// 🔴 Tránh: Effect với logic chỉ nên chạy một lần
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
Tuy nhiên, bạn sẽ nhanh chóng phát hiện ra rằng nó chạy hai lần trong quá trình phát triển. Điều này có thể gây ra sự cố—ví dụ: có thể nó làm mất hiệu lực mã thông báo xác thực vì hàm không được thiết kế để được gọi hai lần. Nói chung, các thành phần của bạn nên có khả năng phục hồi khi được gắn lại. Điều này bao gồm thành phần App
cấp cao nhất của bạn.
Mặc dù nó có thể không bao giờ được gắn lại trong thực tế trong quá trình sản xuất, nhưng việc tuân theo các ràng buộc tương tự trong tất cả các thành phần giúp bạn dễ dàng di chuyển và sử dụng lại code hơn. Nếu một số logic phải chạy một lần cho mỗi lần tải ứng dụng thay vì một lần cho mỗi lần gắn kết thành phần, hãy thêm một biến cấp cao nhất để theo dõi xem nó đã được thực thi hay chưa:
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Chỉ chạy một lần cho mỗi lần tải ứng dụng
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
Bạn cũng có thể chạy nó trong quá trình khởi tạo mô-đun và trước khi ứng dụng render:
if (typeof window !== 'undefined') { // Kiểm tra xem chúng ta có đang chạy trong trình duyệt hay không.
// ✅ Chỉ chạy một lần cho mỗi lần tải ứng dụng
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
Code ở cấp cao nhất chạy một lần khi thành phần của bạn được nhập—ngay cả khi nó không được render. Để tránh chậm trễ hoặc hành vi đáng ngạc nhiên khi nhập các thành phần tùy ý, đừng lạm dụng mẫu này. Giữ logic khởi tạo trên toàn ứng dụng cho các mô-đun thành phần gốc như App.js
hoặc trong điểm nhập của ứng dụng của bạn.
Thông báo cho các thành phần cha về các thay đổi trạng thái
Giả sử bạn đang viết một thành phần Toggle
với trạng thái isOn
bên trong có thể là true
hoặc false
. Có một vài cách khác nhau để chuyển đổi nó (bằng cách nhấp hoặc kéo). Bạn muốn thông báo cho thành phần cha bất cứ khi nào trạng thái bên trong Toggle
thay đổi, vì vậy bạn hiển thị một sự kiện onChange
và gọi nó từ một Effect:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 Avoid: The onChange handler runs too late
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
Giống như trước đây, điều này không lý tưởng. Toggle
cập nhật trạng thái của nó trước, và React cập nhật màn hình. Sau đó, React chạy Effect, gọi hàm onChange
được truyền từ một thành phần cha. Bây giờ thành phần cha sẽ cập nhật trạng thái của chính nó, bắt đầu một lượt render khác. Sẽ tốt hơn nếu thực hiện mọi thứ trong một lượt duy nhất.
Xóa Effect và thay vào đó cập nhật trạng thái của cả hai thành phần trong cùng một trình xử lý sự kiện:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// ✅ Tốt: Thực hiện tất cả các cập nhật trong sự kiện gây ra chúng
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}
Với cách tiếp cận này, cả thành phần Toggle
và thành phần cha của nó đều cập nhật trạng thái của chúng trong sự kiện. React gom các cập nhật từ các thành phần khác nhau lại với nhau, vì vậy sẽ chỉ có một lượt render.
Bạn cũng có thể loại bỏ hoàn toàn trạng thái và thay vào đó nhận isOn
từ thành phần cha:
// ✅ Cũng tốt: thành phần được kiểm soát hoàn toàn bởi thành phần cha
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
// ...
}
“Nâng trạng thái lên” cho phép thành phần cha kiểm soát hoàn toàn Toggle
bằng cách chuyển đổi trạng thái của chính thành phần cha. Điều này có nghĩa là thành phần cha sẽ phải chứa nhiều logic hơn, nhưng sẽ có ít trạng thái tổng thể hơn để lo lắng. Bất cứ khi nào bạn cố gắng giữ cho hai biến trạng thái khác nhau được đồng bộ hóa, hãy thử nâng trạng thái lên thay thế!
Truyền dữ liệu cho thành phần cha
Thành phần Child
này tìm nạp một số dữ liệu và sau đó truyền nó cho thành phần Parent
trong một Effect:
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 Tránh: Truyền dữ liệu cho thành phần cha trong một Effect
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
Trong React, dữ liệu chảy từ các thành phần cha xuống các thành phần con của chúng. Khi bạn thấy điều gì đó không đúng trên màn hình, bạn có thể theo dõi thông tin đến từ đâu bằng cách đi lên chuỗi thành phần cho đến khi bạn tìm thấy thành phần nào truyền sai prop hoặc có trạng thái sai. Khi các thành phần con cập nhật trạng thái của các thành phần cha của chúng trong Effects, luồng dữ liệu trở nên rất khó theo dõi. Vì cả thành phần con và thành phần cha đều cần cùng một dữ liệu, hãy để thành phần cha tìm nạp dữ liệu đó và truyền nó xuống cho thành phần con thay thế:
function Parent() {
const data = useSomeAPI();
// ...
// ✅ Tốt: Truyền dữ liệu xuống cho thành phần con
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
Điều này đơn giản hơn và giữ cho luồng dữ liệu có thể dự đoán được: dữ liệu chảy xuống từ thành phần cha đến thành phần con.
Đăng ký vào một kho bên ngoài
Đôi khi, các thành phần của bạn có thể cần đăng ký vào một số dữ liệu bên ngoài trạng thái React. Dữ liệu này có thể đến từ một thư viện của bên thứ ba hoặc một API trình duyệt tích hợp. Vì dữ liệu này có thể thay đổi mà React không hề hay biết, bạn cần đăng ký thủ công các thành phần của mình vào nó. Điều này thường được thực hiện với một Effect, ví dụ:
function useOnlineStatus() {
// Không lý tưởng: Đăng ký kho thủ công trong một Effect
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
Ở đây, thành phần đăng ký vào một kho dữ liệu bên ngoài (trong trường hợp này, API navigator.onLine
của trình duyệt). Vì API này không tồn tại trên máy chủ (vì vậy nó không thể được sử dụng cho HTML ban đầu), ban đầu trạng thái được đặt thành true
. Bất cứ khi nào giá trị của kho dữ liệu đó thay đổi trong trình duyệt, thành phần sẽ cập nhật trạng thái của nó.
Mặc dù việc sử dụng Effects cho việc này là phổ biến, nhưng React có một Hook được xây dựng có mục đích để đăng ký vào một kho bên ngoài được ưu tiên hơn. Xóa Effect và thay thế nó bằng một lệnh gọi đến useSyncExternalStore
:
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ Tốt: Đăng ký vào một kho bên ngoài với một Hook tích hợp
return useSyncExternalStore(
subscribe, // React sẽ không đăng ký lại miễn là bạn truyền cùng một hàm
() => navigator.onLine, // Cách lấy giá trị trên máy khách
() => true // Cách lấy giá trị trên máy chủ
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
Cách tiếp cận này ít gây ra lỗi hơn so với việc đồng bộ hóa thủ công dữ liệu có thể thay đổi với trạng thái React bằng một Effect. Thông thường, bạn sẽ viết một Hook tùy chỉnh như useOnlineStatus()
ở trên để bạn không cần lặp lại mã này trong các thành phần riêng lẻ. Đọc thêm về đăng ký vào các kho bên ngoài từ các thành phần React.
Tìm nạp dữ liệu
Nhiều ứng dụng sử dụng Effects để bắt đầu tìm nạp dữ liệu. Việc viết một Effect tìm nạp dữ liệu như thế này là khá phổ biến:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 Tránh: Tìm nạp mà không có logic dọn dẹp
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
Bạn không cần phải di chuyển quá trình tìm nạp này sang một trình xử lý sự kiện.
Điều này có vẻ như một mâu thuẫn với các ví dụ trước đó, nơi bạn cần đặt logic vào các trình xử lý sự kiện! Tuy nhiên, hãy xem xét rằng không phải sự kiện gõ là lý do chính để tìm nạp. Các đầu vào tìm kiếm thường được điền trước từ URL và người dùng có thể điều hướng Quay lại và Chuyển tiếp mà không cần chạm vào đầu vào.
Không quan trọng page
và query
đến từ đâu. Trong khi thành phần này hiển thị, bạn muốn giữ cho results
được đồng bộ hóa với dữ liệu từ mạng cho page
và query
hiện tại. Đây là lý do tại sao nó là một Effect.
Tuy nhiên, mã trên có một lỗi. Hãy tưởng tượng bạn gõ "hello"
nhanh. Sau đó, query
sẽ thay đổi từ "h"
, thành "he"
, "hel"
, "hell"
, và "hello"
. Điều này sẽ bắt đầu các quá trình tìm nạp riêng biệt, nhưng không có gì đảm bảo về thứ tự các phản hồi sẽ đến. Ví dụ: phản hồi hell"
có thể đến sau phản hồi "hello"
. Vì nó sẽ gọi setResults()
cuối cùng, bạn sẽ hiển thị sai kết quả tìm kiếm. Điều này được gọi là một “điều kiện cuộc đua”: hai yêu cầu khác nhau “chạy đua” với nhau và đến theo một thứ tự khác với những gì bạn mong đợi.
Để khắc phục điều kiện cuộc đua, bạn cần thêm một hàm dọn dẹp để bỏ qua các phản hồi cũ:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
Điều này đảm bảo rằng khi Effect của bạn tìm nạp dữ liệu, tất cả các phản hồi ngoại trừ phản hồi được yêu cầu cuối cùng sẽ bị bỏ qua.
Xử lý các điều kiện cuộc đua không phải là khó khăn duy nhất khi triển khai tìm nạp dữ liệu. Bạn cũng có thể muốn nghĩ về việc lưu vào bộ nhớ cache các phản hồi (để người dùng có thể nhấp vào Quay lại và xem màn hình trước đó ngay lập tức), cách tìm nạp dữ liệu trên máy chủ (để HTML được hiển thị ban đầu trên máy chủ chứa nội dung đã tìm nạp thay vì một trình quay), và cách tránh các thác nước mạng (để một thành phần con có thể tìm nạp dữ liệu mà không cần chờ đợi mọi thành phần cha).
Những vấn đề này áp dụng cho bất kỳ thư viện giao diện người dùng nào, không chỉ React. Giải quyết chúng không phải là điều tầm thường, đó là lý do tại sao các khung hiện đại cung cấp các cơ chế tìm nạp dữ liệu tích hợp hiệu quả hơn so với việc tìm nạp dữ liệu trong Effects.
Nếu bạn không sử dụng một khung (và không muốn xây dựng khung của riêng bạn) nhưng muốn làm cho việc tìm nạp dữ liệu từ Effects trở nên tiện dụng hơn, hãy cân nhắc trích xuất logic tìm nạp của bạn vào một Hook tùy chỉnh như trong ví dụ này:
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}
Bạn có thể cũng muốn thêm một số logic để xử lý lỗi và theo dõi xem nội dung có đang tải hay không. Bạn có thể xây dựng một Hook như thế này cho chính mình hoặc sử dụng một trong nhiều giải pháp đã có sẵn trong hệ sinh thái React. Mặc dù điều này một mình sẽ không hiệu quả bằng việc sử dụng cơ chế tìm nạp dữ liệu tích hợp của một khung, nhưng việc di chuyển logic tìm nạp dữ liệu vào một Hook tùy chỉnh sẽ giúp bạn dễ dàng áp dụng một chiến lược tìm nạp dữ liệu hiệu quả hơn sau này.
Nói chung, bất cứ khi nào bạn phải dùng đến việc viết Effects, hãy để ý đến khi nào bạn có thể trích xuất một phần chức năng vào một Hook tùy chỉnh với một API khai báo và có mục đích xây dựng hơn như useData
ở trên. Càng ít lệnh gọi useEffect
thô mà bạn có trong các thành phần của mình, bạn sẽ càng thấy dễ dàng hơn để bảo trì ứng dụng của mình.
Tóm tắt
- Nếu bạn có thể tính toán một cái gì đó trong quá trình render, bạn không cần một Effect.
- Để lưu vào bộ nhớ cache các phép tính tốn kém, hãy thêm
useMemo
thay vìuseEffect
. - Để đặt lại trạng thái của toàn bộ cây thành phần, hãy truyền một
key
khác cho nó. - Để đặt lại một bit trạng thái cụ thể để đáp ứng với một thay đổi prop, hãy đặt nó trong quá trình render.
- Mã chạy vì một thành phần đã được hiển thị nên nằm trong Effects, phần còn lại nên nằm trong các sự kiện.
- Nếu bạn cần cập nhật trạng thái của một số thành phần, tốt hơn là thực hiện nó trong một sự kiện duy nhất.
- Bất cứ khi nào bạn cố gắng đồng bộ hóa các biến trạng thái trong các thành phần khác nhau, hãy cân nhắc nâng trạng thái lên.
- Bạn có thể tìm nạp dữ liệu với Effects, nhưng bạn cần triển khai dọn dẹp để tránh các điều kiện cuộc đua.
Challenge 1 of 4: Chuyển đổi dữ liệu mà không cần Effects
TodoList
bên dưới hiển thị một danh sách các todo. Khi hộp kiểm “Chỉ hiển thị các todo đang hoạt động” được đánh dấu, các todo đã hoàn thành sẽ không được hiển thị trong danh sách. Bất kể todo nào hiển thị, chân trang hiển thị số lượng todo chưa hoàn thành.
Đơn giản hóa thành phần này bằng cách loại bỏ tất cả các trạng thái và Effects không cần thiết.
import { useState, useEffect } from 'react'; import { initialTodos, createTodo } from './todos.js'; export default function TodoList() { const [todos, setTodos] = useState(initialTodos); const [showActive, setShowActive] = useState(false); const [activeTodos, setActiveTodos] = useState([]); const [visibleTodos, setVisibleTodos] = useState([]); const [footer, setFooter] = useState(null); useEffect(() => { setActiveTodos(todos.filter(todo => !todo.completed)); }, [todos]); useEffect(() => { setVisibleTodos(showActive ? activeTodos : todos); }, [showActive, todos, activeTodos]); useEffect(() => { setFooter( <footer> {activeTodos.length} todos left </footer> ); }, [activeTodos]); return ( <> <label> <input type="checkbox" checked={showActive} onChange={e => setShowActive(e.target.checked)} /> Show only active todos </label> <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} /> <ul> {visibleTodos.map(todo => ( <li key={todo.id}> {todo.completed ? <s>{todo.text}</s> : todo.text} </li> ))} </ul> {footer} </> ); } function NewTodo({ onAdd }) { const [text, setText] = useState(''); function handleAddClick() { setText(''); onAdd(createTodo(text)); } return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <button onClick={handleAddClick}> Add </button> </> ); }