useState và useEffect: dùng đúng cách
1. Tổng quan
useState và useEffect là hai hook cơ bản nhất của React, nhưng cũng là nơi phát sinh nhiều bug nhất: component render vô hạn, state không cập nhật như mong đợi, effect chạy lặp lại không cần thiết. Bài này đi qua cơ chế hoạt động thực sự của hai hook, các pattern dùng đúng và những lỗi phổ biến bạn nên tránh. Đọc xong, bạn sẽ tự tin hơn khi quyết định “có cần useEffect ở đây không” — câu hỏi mà rất nhiều dev React vẫn trả lời sai.
2. useState cơ bản
useState trả về một cặp [value, setter]. Mỗi lần gọi setter, React lên lịch render lại component với giá trị mới.
import { useState } from "react";
function CustomerCounter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Số khách hàng: {count}</p>
<button onClick={() => setCount(count + 1)}>Thêm khách</button>
</div>
);
}
useState(0)khởi tạocount = 0chỉ ở lần render đầu tiên.setCount(count + 1)tạo một render mới vớicounttăng 1.counttrong closure của lần render hiện tại là immutable — nó không tự đổi giữa hai lần render.
Lưu ý: Gọi
setCountkhông làm thay đổi giá trịcountngay lập tức trong cùng một lần render. React batch các update và render lại sau đó.
3. useState: cập nhật dựa trên state cũ
Nếu bạn gọi setter nhiều lần trong cùng một event handler dựa trên count (biến đã “đóng băng”), kết quả sẽ sai.
function handleTripleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Kết quả: count chỉ tăng 1, không phải 3
}
Dùng functional update để lấy đúng giá trị mới nhất:
function handleTripleClick() {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
// Kết quả: count tăng đúng 3
}
- Khi truyền function vào setter, React đảm bảo
prevluôn là giá trị state mới nhất tại thời điểm xử lý, không phải giá trị đã capture trong closure cũ. - Quy tắc: nếu state mới phụ thuộc state cũ, luôn dùng functional update.
4. useState: lazy initial state
Nếu giá trị khởi tạo cần tính toán tốn kém, đừng gọi hàm trực tiếp — truyền hàm vào useState để nó chỉ chạy một lần.
function parseInitialOrders(raw) {
console.log("Đang parse dữ liệu order...");
return JSON.parse(raw);
}
function OrderList({ rawOrders }) {
// SAI: parseInitialOrders chạy lại ở mọi render
// const [orders, setOrders] = useState(parseInitialOrders(rawOrders));
// ĐÚNG: chỉ chạy ở lần render đầu tiên
const [orders, setOrders] = useState(() => parseInitialOrders(rawOrders));
return <p>Số order: {orders.length}</p>;
}
- Cách sai gọi
parseInitialOrdersở mọi lần render, dù React chỉ dùng kết quả của lần đầu. - Cách đúng truyền một function reference; React chỉ invoke nó khi mount lần đầu.
5. useEffect cơ bản
useEffect chạy một side effect sau khi component render xong (sau khi DOM đã cập nhật).
import { useEffect, useState } from "react";
function PageTitleUpdater({ orderCount }) {
useEffect(() => {
document.title = `Bạn có ${orderCount} order`;
});
return <p>Order hiện tại: {orderCount}</p>;
}
- Không có dependency array (phần thứ hai), effect này chạy sau mọi lần render — cả mount và update.
document.titlelà side effect điển hình: nó tương tác với thế giới bên ngoài React (DOM API), không phải là việc tính toán giá trị để render.
6. useEffect: dependency array
Dependency array quyết định effect chạy khi nào. Có 3 trường hợp cần nhớ rõ.
6.1. Không có dependency array
useEffect(() => {
console.log("Chạy sau MỖI lần render");
});
6.2. Dependency array rỗng []
useEffect(() => {
console.log("Chỉ chạy MỘT LẦN sau khi mount");
}, []);
6.3. Dependency array có giá trị
function CustomerProfile({ customerId }) {
const [customer, setCustomer] = useState(null);
useEffect(() => {
fetchCustomer(customerId).then(setCustomer);
}, [customerId]);
return <p>{customer?.name ?? "Đang tải..."}</p>;
}
- Effect chỉ chạy lại khi
customerIdthay đổi giữa hai lần render (so sánh bằngObject.is). - Nếu bạn thiếu
customerIdtrong array, effect sẽ dùng giá trịcustomerIdcũ (stale closure) — fetch sai khách hàng khi prop đổi.
Lưu ý: ESLint plugin
eslint-plugin-react-hooksvới ruleexhaustive-depssẽ cảnh báo khi bạn thiếu dependency. Đừng tắt rule này trừ khi bạn hiểu rõ hệ quả.
7. useEffect: cleanup function
Khi effect tạo ra subscription, timer, hay event listener, bạn phải return một hàm cleanup để tránh leak.
function OrderStatusListener({ orderId }) {
const [status, setStatus] = useState("pending");
useEffect(() => {
const subscription = subscribeToOrderStatus(orderId, setStatus);
return () => {
subscription.unsubscribe();
};
}, [orderId]);
return <p>Trạng thái: {status}</p>;
}
- Hàm cleanup chạy trước khi effect chạy lại (khi
orderIdđổi) và khi component unmount. - Nếu không cleanup, mỗi lần
orderIdđổi sẽ tạo thêm một subscription mới — cuối cùngsetStatusbị gọi từ nhiều subscription cũ, gây cập nhật sai hoặc memory leak.
Thứ tự chạy khi orderId đổi từ 1 -> 2:
1. cleanup của effect với orderId = 1
2. effect mới chạy với orderId = 2
8. Lỗi thường gặp
8.1. Infinite loop do thiếu dependency đúng
function BadExample() {
const [data, setData] = useState({ count: 0 });
useEffect(() => {
// object mới mỗi lần render -> effect luôn thấy "thay đổi" nếu đưa data vào deps
setData({ count: data.count + 1 }); // gây re-render -> effect chạy lại -> vô hạn
}, [data]);
return <p>{data.count}</p>;
}
Sửa bằng functional update và tách biệt điều kiện dừng, hoặc bỏ hẳn useEffect nếu logic này không cần side effect thật sự.
8.2. Stale closure trong setTimeout/setInterval
function Countdown() {
const [seconds, setSeconds] = useState(10);
useEffect(() => {
const id = setInterval(() => {
setSeconds((prev) => prev - 1); // dùng functional update, KHÔNG dùng seconds trực tiếp
}, 1000);
return () => clearInterval(id);
}, []); // deps rỗng vì không đọc seconds trực tiếp trong effect
return <p>{seconds}</p>;
}
- Nếu viết
setSeconds(seconds - 1)thay vì functional update, closure sẽ luôn “nhớ”seconds = 10từ lần effect chạy đầu tiên — countdown bị kẹt ở giá trị sai.
8.3. Dùng useEffect để tính giá trị derived
// SAI: dư thừa, gây render thêm một lần
function OrderSummaryBad({ items }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
return <p>Tổng: {total}</p>;
}
// ĐÚNG: tính trực tiếp trong lúc render
function OrderSummaryGood({ items }) {
const total = items.reduce((sum, i) => sum + i.price, 0);
return <p>Tổng: {total}</p>;
}
Bản “SAI” tốn thêm một cặp render: render với total cũ, effect chạy, setTotal trigger render thứ hai với giá trị đúng. Bản “ĐÚNG” tính ngay trong render, không cần effect, không cần state phụ.
9. Khi nào KHÔNG nên dùng useEffect
Lưu ý:
useEffectchỉ dành cho việc đồng bộ hóa với hệ thống bên ngoài React (DOM, API, subscription, timer). Nếu bạn có thể tính giá trị trực tiếp từ props/state hiện có, đừng dùng effect.
Ba dấu hiệu bạn đang dùng sai:
- Bạn dùng effect để tính một giá trị derived từ state khác (dùng biến thường hoặc
useMemo). - Bạn dùng effect để xử lý event của người dùng (xử lý trực tiếp trong handler
onClick,onSubmit). - Bạn dùng effect để reset state khi prop đổi — thường có thể dùng
keyprop để React tự remount component thay vì effect.
// Thay vì dùng effect để reset form khi customerId đổi:
// useEffect(() => { setForm(initialForm); }, [customerId]);
// Dùng key để remount component, React tự reset toàn bộ state nội bộ:
<CustomerForm key={customerId} customerId={customerId} />
10. Viết test cho hành vi của hook
Dùng Jest kèm React Testing Library để kiểm tra hành vi thực tế thay vì implementation detail.
npm install --save-dev @testing-library/react @testing-library/user-event jest
// CustomerCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomerCounter } from "./CustomerCounter";
test("tăng count khi click nút Thêm khách", async () => {
const user = userEvent.setup();
render(<CustomerCounter />);
expect(screen.getByText("Số khách hàng: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Thêm khách" }));
expect(screen.getByText("Số khách hàng: 1")).toBeInTheDocument();
});
- Test gọi
renderrồi mô phỏng click thật quauserEvent, giống hành vi người dùng. - Assertion dựa trên nội dung hiển thị (
getByText), không dựa vào state nội bộ — đây là cách test đúng với React: test behavior, không test implementation.
PASS ./CustomerCounter.test.jsx
✓ tăng count khi click nút Thêm khách (45 ms)
11. Kết luận
useState đơn giản ở bề ngoài nhưng dễ gây lỗi khi bạn quên rằng giá trị trong closure là “đóng băng” theo từng lần render — hãy dùng functional update khi state mới phụ thuộc state cũ. useEffect mạnh nhưng chỉ nên dùng để đồng bộ với hệ thống ngoài React; nếu một giá trị có thể tính trực tiếp trong render, đừng bọc nó trong effect. Nắm chắc dependency array và cleanup function sẽ giúp bạn tránh gần hết các bug phổ biến liên quan đến hai hook này.