⚙️ useEffect và side effects

By Better Wise

Từ form controlled đến những việc React không tự làm

Ở bài trước, form controlled dạy ta một điều cốt lõi: state là nguồn sự thật duy nhất, còn giao diện chỉ là kết quả render từ state. Nhưng có những việc không phải là "vẽ giao diện": đổi tiêu đề tab trình duyệt, lưu dữ liệu vào localStorage, đặt một setInterval, hay gọi API lấy danh sách công việc. React gọi chung những việc đó là side effect, và useEffect là chỗ để đặt chúng.

Sau 30 phút này, bạn sẽ:

  • Giải thích được side effect là gì và khi nào thật sự cần useEffect
  • Viết effect chạy đúng một lần, chạy lại khi giá trị phụ thuộc thay đổi, hoặc chạy sau mỗi lần render
  • Nhận ra và sửa được vòng lặp vô hạn do setState nằm trong effect
  • Trả về cleanup function để dọn timer và event listener
  • Biết những trường hợp không cần useEffect

beginner

Side effect là gì và React đặt nó ở đâu

Hãy tưởng tượng bạn viết HTML thuần. Muốn đổi tiêu đề tab, bạn viết document.title = "...". Muốn lưu ghi chú, bạn gọi localStorage.setItem(...). Những dòng đó không tạo ra phần tử nào trên trang, chúng tác động ra bên ngoài component.

Trong React, phần thân component phải "thuần": nhận props và state, trả về JSX. Nếu bạn gọi API hay đặt timer ngay trong thân component, code đó sẽ chạy lại mỗi lần render, và bạn mất kiểm soát. Vì vậy React tách riêng một chỗ cho các tác động đó:

import { useEffect, useState } from "react";

function TodoApp() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    document.title = `${todos.length} công việc`;
  }, [todos]);

  return <h1>Danh sách công việc</h1>;
}

useEffect nhận hai tham số: một hàm chứa side effect, và một dependency array cho biết effect phụ thuộc vào giá trị nào. Điểm quan trọng: effect chạy sau khi React đã render và cập nhật DOM xong, nên bên trong effect bạn luôn đọc được giao diện mới nhất.

flowchart TD
    A["🔁 Render component"] --> B["🖼️ Cập nhật DOM"]
    B --> C{"Dependency thay đổi?"}
    C -->|Có| D["🧹 Chạy cleanup cũ"]
    D --> E["⚡ Chạy effect mới"]
    C -->|Không| F["⏭️ Bỏ qua effect"]

Dependency array quyết định effect chạy lại khi nào

Đây là phần gây lỗi nhiều nhất với người mới. Có đúng ba kiểu viết, và mỗi kiểu có hệ quả rõ ràng:

Dependency array Khi nào effect chạy
Không truyền Sau mỗi lần render
Mảng rỗng [] Một lần duy nhất, sau lần render đầu tiên (mount)
[a, b] Sau lần render đầu tiên và mỗi khi a hoặc b thay đổi

Ví dụ thực tế trong app quản lý công việc của chúng ta:

// Chỉ lấy dữ liệu mẫu một lần khi mở app
useEffect(() => {
  const duLieuMau = [{ id: 1, ten: "Học useEffect", done: false }];
  setTodos(duLieuMau);
}, []);

// Lưu lại mỗi khi danh sách thay đổi
useEffect(() => {
  localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);

Note: React so sánh các phần tử trong dependency array bằng Object.is. Với số, chuỗi, boolean thì dễ đoán. Với object, array hay function thì mỗi lần render tạo ra một tham chiếu mới, nên effect sẽ chạy lại liên tục dù nội dung không đổi. Đây là lý do bạn sẽ gặp cảnh báo về dependency trong trình duyệt.

Bài tập 1. Trong app quản lý công việc, hãy viết effect cập nhật document.title thành số việc chưa hoàn thành, và chỉ chạy lại khi todos thay đổi.

Xem gợi ý

useEffect(() => {
  const chuaXong = todos.filter((todo) => !todo.done).length;
  document.title = `${chuaXong} việc chưa xong`;
}, [todos]);

Nếu bạn bỏ [todos], effect vẫn ra kết quả đúng nhưng chạy thừa sau mọi lần render, kể cả khi chỉ gõ vào ô input không liên quan.

Vì sao vòng lặp vô hạn xuất hiện

Vòng lặp vô hạn trong useEffect là lỗi kinh điển của người mới, và nó luôn theo cùng một công thức: effect cập nhật state, cập nhật state làm render lại, render lại kích hoạt effect.

// SAI: trang web treo, tab trình duyệt quay mòng mòng
useEffect(() => {
  setCount(count + 1);
});

Không có dependency array nghĩa là "chạy sau mỗi lần render". Mà mỗi lần chạy lại gọi setCount, tạo ra một lần render mới. Vòng này không bao giờ dừng.

Cách sửa phụ thuộc vào ý định thật của bạn:

// Ý định: chỉ tăng một lần khi mở app
useEffect(() => {
  setCount((c) => c + 1);
}, []);

// Ý định: đồng bộ theo một giá trị khác
useEffect(() => {
  setCount(soLuongMoi);
}, [soLuongMoi]);

Một biến thể khác cũng rất hay gặp: effect phụ thuộc vào một object được tạo mới mỗi lần render như [user] hay [options]. Cách xử lý là đưa đúng giá trị nguyên thủy vào deps, ví dụ [user.id], hoặc đưa việc tạo object ra ngoài.

Bài tập 2. Đoạn code sau gây vòng lặp vô hạn. Tìm nguyên nhân và sửa lại sao cho danh sách chỉ được nạp một lần khi mở app.

useEffect(() => {
  setTodos([{ id: 1, ten: "Đi chợ", done: false }]);
}, [todos]);

Xem gợi ý

[todos] nằm trong deps, mà effect lại gọi setTodos tạo mảng mới, nên todos đổi, effect chạy lại, và cứ thế. Nạp dữ liệu mẫu một lần thì dùng mảng rỗng:

useEffect(() => {
  setTodos([{ id: 1, ten: "Đi chợ", done: false }]);
}, []);

Cleanup: dọn dẹp trước khi effect chạy lại

Effect không chỉ cần chạy, nó còn cần được gỡ khi không còn dùng nữa. Muốn vậy, hàm bên trong effect trả về một hàm khác, gọi là cleanup function. React gọi cleanup ngay trước lần chạy tiếp theo của effect, và một lần nữa khi component bị unmount.

useEffect(() => {
  const id = setInterval(() => {
    console.log("Đang kiểm tra dữ liệu...");
  }, 5000);

  return () => clearInterval(id); // cleanup
}, []);

Nếu thiếu clearInterval, mỗi lần component mount lại bạn có thêm một timer sống mãi, và app sẽ ngày càng nặng. Quy tắc dễ nhớ: cái gì effect bật lên thì cleanup phải tắt đi (timer, event listener, subscription).

Tip: Ở chế độ dev, React StrictMode cố tình mount, unmount rồi mount lại để kiểm tra cleanup. Nếu bạn thấy log chạy hai lần, đó không phải bug, đó là tín hiệu tốt cho thấy bạn đã viết cleanup đúng.

Những lỗi người mới hay mắc với useEffect

  • Dùng effect cho việc tính toán được ngay trong render. Nếu bạn chỉ cần const tong = items.reduce(...) thì tính trực tiếp, đừng đưa vào state rồi đồng bộ bằng effect. Effect chỉ dành cho tác động ra bên ngoài.
  • Để deps trống cho có, rồi tắt cảnh báo của ESLint. Cảnh báo dependency chính là công cụ bắt lỗi cũ nhất của bạn. Đọc nó, đừng bỏ qua.
  • Quên cleanup với timer và event listener. Mỗi lần rời trang mà không gỡ là một rò rỉ nhỏ.
  • Đặt async trực tiếp vào effect. useEffect(async () => {...}) trả về Promise chứ không phải hàm cleanup. Hãy khai báo một hàm async bên trong rồi gọi nó.
  • Coi effect là nơi "chạy khi state đổi". Nếu bạn chỉ cần giá trị mới của state để render, hãy render thẳng từ state đó.

Ghi nhớ nhanh và bước tiếp theo

Tóm lại: side effect là những việc vượt ra ngoài việc trả về JSX, và useEffect là chỗ đặt chúng sau khi DOM đã cập nhật. Dependency array quyết định nhịp chạy: không truyền thì chạy mọi lần render, [] thì chạy một lần, [a, b] thì chạy lại khi a hoặc b đổi. Vòng lặp vô hạn gần như luôn đến từ việc setState trong effect mà deps không chặn được, và cleanup function là thứ giữ cho timer cùng event listener không tích tụ lại.

Bài tiếp theo sẽ đưa useEffect vào đúng công việc quan trọng nhất của nó: gọi API bằng fetch, với ba trạng thái loading, error và dữ liệu, kèm cách cleanup khi người dùng rời trang giữa lúc request còn đang bay.

Front: Side effect là gì? Back: Việc component tác động ra bên ngoài phần render JSX, ví dụ đổi document.title, gọi API, đặt timer, đọc localStorage.

Front: useEffect chạy vào thời điểm nào? Back: Sau khi React render xong và đã cập nhật DOM.

Front: Tham số thứ hai của useEffect quyết định điều gì? Back: Dependency array, tức là khi nào effect được chạy lại.

Front: Effect không truyền dependency array sẽ chạy khi nào? Back: Sau mỗi lần render.

Front: Dependency array rỗng [] khiến effect chạy khi nào? Back: Một lần duy nhất, sau lần render đầu tiên (mount).

Front: Effect có deps [todos] chạy lại khi nào? Back: Khi todos thay đổi so với lần render trước.

Front: Nguyên nhân phổ biến nhất của vòng lặp vô hạn trong useEffect? Back: Effect gọi setState mà dependency array không chặn được, nên mỗi render lại kích hoạt effect.

Front: Cleanup function là gì? Back: Hàm được effect return về, chạy trước lần effect kế tiếp và khi component unmount.

Front: Khi nào bắt buộc phải có cleanup? Back: Khi effect đăng ký thứ gì đó cần gỡ: setInterval, event listener, subscription.

Front: Có nên dùng useEffect để tính giá trị dẫn xuất từ props hoặc state? Back: Không, hãy tính trực tiếp trong lúc render.

Front: Vì sao đưa object hoặc function vào dependency array dễ gây chạy lại effect? Back: Mỗi lần render tạo tham chiếu mới, nên React luôn thấy giá trị đã thay đổi.

Front: Tại sao không viết async trực tiếp cho hàm của useEffect? Back: Hàm async trả về Promise, không phải cleanup function mà React cần.

Front: Vì sao effect chạy hai lần ở môi trường dev? Back: Do React StrictMode cố tình mount, unmount, mount lại để kiểm tra cleanup.

useEffect được dùng để chạy _____ sau khi component render và DOM đã cập nhật. Dependency array rỗng _____ khiến effect chỉ chạy một lần sau lần render đầu tiên. Effect không truyền dependency array sẽ chạy sau _____. Hàm được return từ bên trong effect gọi là _____, chạy trước lần effect kế tiếp và khi unmount. Gọi setState bên trong effect mà không có dependency array phù hợp sẽ gây ra _____.