Trong Strict Mode ở môi trường phát triển, React 18 cố ý chạy effect hai lần: mount → cleanup → mount lại. Đây không phải lỗi và cũng không xảy ra ở production — nó là bài kiểm tra xem effect của bạn có dọn dẹp đúng không.
Lý do: React đang chuẩn bị cho khả năng giữ lại state của component khi người dùng rời đi rồi quay lại. Muốn vậy thì mọi effect phải chạy lại được mà không gây tác dụng phụ. Cách nhanh nhất để lộ ra effect không thoả điều kiện đó là chạy nó hai lần ngay khi bạn đang code.
Effect viết đúng thì hai lần chạy không để lại dấu vết:
// Không có cleanup: hai kết nối, một cái rò rỉ
useEffect(() => {
const conn = createConnection(roomId)
conn.connect()
}, [roomId])
// Có cleanup: kết nối rồi ngắt, chạy bao nhiêu lần cũng sạch
useEffect(() => {
const conn = createConnection(roomId)
conn.connect()
return () => conn.disconnect()
}, [roomId])Với fetch, cách dọn là bỏ qua kết quả đã cũ hoặc huỷ hẳn request:
useEffect(() => {
const ac = new AbortController()
fetch(url, { signal: ac.signal }).then(setData).catch(() => {})
return () => ac.abort()
}, [url])Đây cũng chính là lời giải cho câu hỏi "vì sao API bị gọi hai lần" mà rất nhiều người gặp khi mới lên React 18.
Lưu ý: Đừng chữa bằng cách tắt Strict Mode hay dùng useRef làm cờ chặn lần chạy thứ hai. Hai cách đó giấu triệu chứng đi; effect vẫn thiếu cleanup, và lỗi sẽ quay lại dưới dạng rò rỉ kết nối hoặc race condition ở production.