Cảnh Báo Mà Lập Trình Viên Hay Bỏ Qua
Bạn đã thấy nó cả trăm lần:
React Hook useEffect has a missing dependency: 'someVariable'. Either include it or remove the dependency array. react-hooks/exhaustive-deps
Phản ứng thông thường? Thêm // eslint-disable-next-line vào bên trên rồi tiếp tục. Đó là ý tưởng tồi. Cảnh báo này tồn tại vì các dependency bị thiếu gây ra những bug thực sự, rất khó phát hiện — loại bug chỉ xuất hiện trên production sau một chuỗi thao tác cụ thể của người dùng, và phải mất hàng giờ mới truy ra được nguyên nhân do stale closure.
Một Tình Huống Bug Cụ Thể
Đây là pattern bug tôi đã thấy trong ít nhất một chục codebase:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, []); // ← ESLint cảnh báo: 'userId' bị thiếu
}
Trông có vẻ ổn, phải không? Bây giờ hãy tưởng tượng component cha truyền vào một userId khác — chẳng hạn khi người dùng nhấp vào xem hồ sơ của người khác. Effect sẽ không bao giờ chạy lại. Dữ liệu cũ vẫn hiển thị trên màn hình. Component âm thầm hiển thị thông tin của người dùng sai.
ESLint đã bắt được điều này. Mảng rỗng [] nói với React "chạy một lần, không bao giờ chạy lại" — nhưng effect rõ ràng phụ thuộc vào userId để hoạt động đúng.
Tại Sao Dependency Array Tồn Tại
React chạy lại useEffect mỗi khi có bất kỳ giá trị nào trong dependency array thay đổi. Bỏ sót một giá trị mà effect thực sự sử dụng, và React sẽ không bao giờ biết cần chạy lại. Effect lưu giữ một snapshot cũ của biến đó và không bao giờ cập nhật — đây là lỗi closure cổ điển.
Rule ESLint react-hooks/exhaustive-deps phân tích tĩnh phần thân effect của bạn và đánh dấu mọi biến cần có trong mảng. Nó gây khó chịu. Nhưng nó cũng gần như luôn luôn đúng.
Ba Cách Sửa
Cách Sửa 1: Thêm Dependency Còn Thiếu
Chín trên mười lần, câu trả lời đơn giản là: thêm nó vào.
// Trước (có bug)
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, []);
// Sau (đúng)
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]);
Bây giờ mỗi khi userId thay đổi, effect sẽ fetch lại dữ liệu mới. Đó chính xác là hành vi bạn muốn.
Cách Sửa 2: Function Là Dependency — Dùng useCallback
Đây là nơi mọi thứ trở nên phức tạp hơn. Các function được định nghĩa bên trong component sẽ được tạo lại mỗi lần render. Thêm một function vào dependency array mà không ổn định hóa nó trước, bạn sẽ gặp vòng lặp vô hạn.
// Vấn đề: fetchData là một reference mới mỗi lần render → vòng lặp vô hạn
function SearchPage({ query }) {
const fetchData = () => fetch(`/api/search?q=${query}`);
useEffect(() => {
fetchData().then(/* ... */);
}, [fetchData]); // fetchData khác nhau mỗi lần render!
}
Bọc function trong useCallback để nó chỉ thay đổi khi dependency của chính nó thay đổi:
function SearchPage({ query }) {
const fetchData = useCallback(() => {
return fetch(`/api/search?q=${query}`);
}, [query]); // reference ổn định; chỉ cập nhật khi query thay đổi
useEffect(() => {
fetchData().then(/* ... */);
}, [fetchData]); // bây giờ an toàn
}
Cách Sửa 3: Chuyển Function Vào Bên Trong Effect
Nếu function đó chỉ được dùng bởi một effect, hãy bỏ qua useCallback và định nghĩa nó bên trong:
useEffect(() => {
const fetchData = () => fetch(`/api/search?q=${query}`);
fetchData().then(data => setResults(data));
}, [query]); // chỉ cần query — gọn gàng và rõ ràng
Ít gián tiếp hơn. Dễ đọc hơn. Thường là lựa chọn đúng đắn.
Khi Nào Mảng Rỗng Thực Sự Đúng
Đôi khi bạn thực sự muốn hành vi chỉ chạy khi mount — thiết lập WebSocket, khởi tạo SDK bên thứ ba, đăng ký event listener toàn cục. Nếu effect không có dependency reactive, [] là đúng:
useEffect(() => {
const socket = io('wss://example.com');
socket.on('message', handleMessage);
return () => socket.disconnect();
}, []); // đúng: kết nối một lần, ngắt kết nối khi unmount
Điểm cần lưu ý: nếu handleMessage đọc state hoặc props, bạn vẫn gặp vấn đề stale closure. Dùng ref để cho handler truy cập giá trị mới mà không cần tạo lại socket:
const handleMessageRef = useRef(handleMessage);
useEffect(() => {
handleMessageRef.current = handleMessage;
});
useEffect(() => {
const socket = io('wss://example.com');
socket.on('message', (msg) => handleMessageRef.current(msg));
return () => socket.disconnect();
}, []);
Comment Disable — Dùng Có Chủ Đích, Không Phải Lười Biếng
eslint-disable không phải lúc nào cũng sai. Nó sai khi được dùng để tắt tiếng một cảnh báo mà bạn chưa hiểu. Khi dùng có chủ đích, nó hoàn toàn ổn:
useEffect(() => {
// Cố ý: chỉ log giá trị lúc mount, không phải mỗi lần thay đổi
console.log('Initial count:', count);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Luôn thêm comment giải thích lý do tại sao. Bạn trong tương lai, đang xem lại đoạn này lúc 11 giờ đêm giữa một sự cố, sẽ biết ơn điều đó. Đừng bao giờ tắt rule chỉ để làm mất đi cái gạch chân màu vàng.
Các Bước Kiểm Tra
- Kiểm tra output ESLint: Lưu file. Cảnh báo sẽ biến mất khỏi editor và khỏi
npx eslint src/. - Kiểm tra trường hợp động: Nếu effect phụ thuộc vào prop như
userId, hãy thực sự thay đổi prop đó — điều hướng sang user khác, chuyển filter — và xác nhận effect chạy lại với dữ liệu mới. - Chú ý vòng lặp vô hạn: Đã thêm function vào dependency array? Mở tab Network và kiểm tra xem các request có liên tục bắn không. Nếu có, hãy áp dụng Cách Sửa 2 hoặc 3.
- Chạy bộ test: Các effect giờ chạy lại khi prop thay đổi đôi khi sẽ lộ ra những test đang âm thầm pass do stale closure. Những test đó đang kiểm tra sai thứ — hãy sửa chúng.
Tóm Tắt Nhanh
- Thiếu kiểu nguyên thủy (string, number, boolean) → thêm vào mảng
- Thiếu object hoặc array → xác minh nó ổn định (từ
useState,useRef, hoặcuseMemo); memoize nếu không - Thiếu function từ component body → chuyển vào bên trong effect, hoặc bọc trong
useCallback - Thiếu function từ props → bọc trong
useCallbacktại nơi gọi, hoặc chuyển vào bên trong effect - Effect thực sự chỉ chạy khi mount → mảng rỗng là đúng; thêm comment giải thích nếu bạn tắt lint rule

