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ạo count = 0 chỉ ở lần render đầu tiên.
  • setCount(count + 1) tạo một render mới với count tăng 1.
  • count trong 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 setCount không làm thay đổi giá trị count ngay 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 prev luô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.title là 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 customerId thay đổi giữa hai lần render (so sánh bằng Object.is).
  • Nếu bạn thiếu customerId trong array, effect sẽ dùng giá trị customerId cũ (stale closure) — fetch sai khách hàng khi prop đổi.

Lưu ý: ESLint plugin eslint-plugin-react-hooks với rule exhaustive-deps sẽ 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ùng setStatus bị 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 = 10 từ 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 ý: useEffect chỉ 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 key prop để 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 render rồi mô phỏng click thật qua userEvent, 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.

reacthooksjavascriptfrontendtypescript