Mục lục

30+ Data Fetching Pitfalls: Phân tích & Giải pháp chuyên sâu

Cẩm nang chi tiết từng case study: Phân tích nguyên nhân gốc rễ, ví dụ code lỗi vs code sạch, và ghi chú quan trọng cho 30+ lỗi Data Fetching thường gặp.

Đây là tài liệu phân tích sâu (Deep Dive) về cơ chế hoạt động của JS/React dẫn đến các lỗi Data Fetching kinh điển.


Nhóm 1: React Lifecycle & State Risks (Bugs Logic)

1. Race Condition (Cuộc đua dữ liệu) - Phân tích "Mổ xẻ"

Kịch bản thực tế: User đang ở trang Profile ID=1. Mạng chậm (3G). User bấm nhanh sang Profile ID=2. Mạng nhanh (Wifi).

Timeline thảm họa (Không có cleanup):

text:
T0: User bấm ID=1 -> Gửi Request 1 (Pending...)
T1: User bấm ID=2 -> Gửi Request 2 (Pending...)
T2: Request 2 trả về (Data ID=2) -> setProfile(Data 2) -> UI hiện ID=2 (ĐÚNG)
T3: ... Request 1 vẫn đang chạy ...
T4: Request 1 trả về (Data ID=1) -> setProfile(Data 1) -> UI nhảy sang ID=1 (SAI!)
>> KẾT QUẢ: URL là ID=2 nhưng Màn hình hiện ID=1.

Nguyên nhân gốc rễ (Root Cause): Do tính chất bất đồng bộ (Async) của JavaScript. Các Promise không trả về theo thứ tự gửi đi (FIFO), mà trả về theo tốc độ mạng. React Component thì "ngây thơ", cứ thấy Promise nào về là setState cái đó, không quan tâm nó có còn là "current" hay không.

Giải pháp chuyên sâu: AbortController: Đây là API chuẩn của trình duyệt (Browser API), không phải của React. Khi gọi controller.abort():

  1. Browser lập tức đóng kết nối network (Socket close).
  2. Promise của fetch sẽ reject ngay lập tức với lỗi AbortError.
  3. Trong block .catch, ta check lỗi này và bỏ qua -> Không gọi setState -> UI được bảo toàn.

Code chuẩn chỉnh:

tsx:
useEffect(() => {
  const controller = new AbortController();
  
  const fetchedData = fetch(`/api/user/${id}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => {
        // Nếu component đã unmount hoặc id đổi -> Effect cleanup chạy -> request bị abort
        // -> Dòng này sẽ không bao giờ chạy được vì fetch đã bị reject ở trên
        setData(data); 
    })
    .catch(err => {
       if (err.name === 'AbortError') {
         console.log('🛑 Request cũ đã bị hủy đúng như kế hoạch');
         return; // Quan trọng: Return luôn, không setError
       }
       setError(err);
    });

  return () => {
     // Hàm này chạy TRƯỚC KHI effect lần tiếp theo chạy
     console.log('🧹 Cleanup effect cũ');
     controller.abort();
  };
}, [id]); // Chạy lại mỗi khi ID đổi

2. Stale Closure (Hàm đóng bị ôi thiu)

Kịch bản: Bạn muốn log ra giá trị count mỗi giây.

tsx:
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Effect này chỉ chạy 1 lần duy nhất khi Mount
    const timer = setInterval(() => {
      console.log('Count is:', count); // ❌ Luôn in ra "Count is: 0"
    }, 1000);
    return () => clearInterval(timer);
  }, []); // Deps rỗng
  
  return <button onClick={() => setCount(c => c + 1)}>Tăng</button>;
}

Nguyên nhân gốc rễ (Root Cause): Đây là cơ chế Closure của JavaScript.

  1. Lần render đầu (Count=0): Hàm useEffect chạy. Nó tạo ra một hàm con () => console.log(count). Hàm con này "chụp ảnh" (capture) môi trường xung quanh nó lúc đó -> Nó thấy count đang là 0. Nó lưu cái số 0 này mãi mãi vào bộ nhớ của nó.
  2. Lần render hai (Count=1): useEffect KHÔNG chạy lại (vì deps rỗng). Cái hàm setInterval cũ vẫn đang chạy với tấm ảnh count=0 cũ mèm.

Giải pháp:

  1. Cách 1 (Dependency): Thêm [count] vào dependency. => Nhược điểm: Mỗi lần count đổi -> Clear Interval cũ -> Tạo Interval mới. Timer bị reset liên tục (không chính xác).
  2. Cách 2 (Ref - Mutable): Dùng useRef để "xuyên thủng" Closure. ref là một cái hộp giống nhau qua các lần render. Closure giữ tham chiếu đến cái hộp ref. Dù state đổi, cái hộp vẫn là cái hộp đó, nhưng giá trị bên trong (current) đã được cập nhật.
tsx:
const countRef = useRef(count);
// Luôn update giá trị mới nhất vào hộp
useEffect(() => { countRef.current = count }, [count]);

useEffect(() => {
  setInterval(() => {
    // Luôn đọc từ hộp ra
    console.log('Count is:', countRef.current); // ✅ 0, 1, 2...
  }, 1000);
}, []);

Nhóm 2: Performance Bottlenecks (Sát thủ hiệu năng)

3. Memory Leak on Unmount

  • Kịch bản: User vào trang -> Fetch data -> Bấm Back ngay lập tức. Component đã hủy nhưng Fetch vẫn chạy xong và gọi setState -> Console báo lỗi đỏ lòm.
  • Nguyên nhân: Cố gắng update state cho một component đã chết.
  • Giải pháp: Dùng biến cờ isMounted.
  • Code:
    tsx:
    useEffect(() => {
      let isMounted = true;
      fetchData().then(data => {
        if (isMounted) setData(data);
      });
      return () => { isMounted = false; };
    }, []);
  • 📝 Ghi nhớ: "Dọn dẹp hiện trường (Cleanup) trước khi rời đi."

4. Infinite Loop Fetching

  • Kịch bản: Browser bị treo, quạt máy tính rú lên vì gọi API liên tục 1000 lần/giây.
  • Nguyên nhân: useEffect phụ thuộc vào một object được tạo mới mỗi lần render (dep={{ id: 1 }}). {id:1} !== {id:1} trong JS -> Effect chạy lại -> Set State -> Render lại -> Effect chạy lại...
  • Giải pháp: Dùng useMemo hoặc chuyển biến ra ngoài component.
  • 📝 Ghi nhớ: "Cẩn thận với Object/Array trong Dependency Array."

5. Flash of Old Content (FOC)

  • Kịch bản: Search "A" -> Hiện kết quả A. Search "B" -> Vẫn hiện kết quả A trong 1 giây loading -> Mới hiện B.
  • Nguyên nhân: Không xóa state cũ khi bắt đầu fetch mới.
  • Giải pháp: Reset state ngay đầu hàm fetch.
    tsx:
    const handleSearch = async (query) => {
      setResults([]); // ✅ Xóa ngay lập tức
      setLoading(true);
      const data = await fetch(...);
      setResults(data);
    };
  • 📝 Ghi nhớ: "Thà hiện Loading còn hơn hiện tin cũ gây hiểu lầm."

7. Waterfalls (Thác nước)

  • Kịch bản: Tổng thời gian load = Thời gian A + Thời gian B + Thời gian C.
  • Nguyên nhân: await tuần tự những thứ không phụ thuộc nhau.
  • Giải pháp: Promise.all.
  • Code:
    tsx:
    // ✅ Song song
    const [user, posts] = await Promise.all([getUser(), getPosts()]);
  • 📝 Ghi nhớ: "Cái gì chạy song song được thì ĐỪNG bắt nó xếp hàng."

8. N+1 Problem (Frontend Version)

  • Kịch bản: Load list 100 Users. Trong mỗi User component lại có cái useEffect fetch Avatar riêng. -> 101 requests.
  • Nguyên nhân: Chia nhỏ component quá mức nhưng thiếu cơ chế Batching.
  • Giải pháp: Fetch 1 cục to (list users kèm avatars) từ cha rồi truyền props xuống.
  • 📝 Ghi nhớ: "Fetch sớm (ở cha), fetch một lần (Bulk fetch)."

9. Over-fetching (Thừa mứa)

  • Kịch bản: Cần hiển thị username, nhưng API trả về cả object User 5MB gồm lịch sử log chat.
  • Nguyên nhân: Backend lười tạo API riêng, Frontend lười lọc data.
  • Giải pháp: GraphQL hoặc Backend-For-Frontend (BFF) pattern để lọc data.
  • 📝 Ghi nhớ: "Chỉ tải những gì User nhìn thấy."

Nhóm 3: Network & Infrastructure (Mạng & Server)

13. API Timeouts (Treo vô tận)

  • Kịch bản: Server Backend bị Deadlock treo cứng. Frontend cứ quay loading mãi mãi (User tưởng mạng lag).
  • Nguyên nhân: fetch mặc định không có timeout (chờ vô tận).
  • Giải pháp: AbortSignal.timeout().
  • Code: fetch(url, { signal: AbortSignal.timeout(5000) }).
  • 📝 Ghi nhớ: "Fail Fast (Chết nhanh) tốt hơn là Treo."

14. Retry Storm (Bão Retry)

  • Kịch bản: Server vừa sập. 10.000 users thấy lỗi đồng loạt F5 hoặc App tự động retry cùng lúc -> Server vừa ngóc đầu lên lại bị đấm sập tiếp.
  • Nguyên nhân: Cơ chế Retry ngây thơ (thử lại ngay lập tức).
  • Giải pháp: Exponential Backoff + Jitter (Người chờ 1s, người chờ 1.5s, người chờ 3s...).
  • 📝 Ghi nhớ: "Đừng dồn dập, hãy cho Server thở."

15. Cache Stampede (Cơn lũ Cache) - Phân tích "Deep Dive"

Kịch bản thảm họa:

  1. Bạn có key Redis product:1 chứa thông tin sản phẩm "Hot". TTL = 5 phút.
  2. Lúc 12:00:00, Cache hết hạn.
  3. Lúc 12:00:01, có 5000 requests cùng lúc truy cập sản phẩm này.
  4. Cả 5000 Requests thấy Cache rỗng -> Cả 5000 Requests cùng lúc đâm thủng Redis và tấn công trực tiếp Database MySQL.
  5. Database sập. Hệ thống sập.

Giải pháp 1: Active Validation (Locking)

  • Người đầu tiên thấy Cache hết hạn -> Đặt một cái Lock: "Tao đang đi lấy data mới, bọn mày chờ đi".
  • 4999 người còn lại thấy Lock -> Chờ 100ms rồi check lại cache.
  • -> Chỉ có 1 Request DB.

Giải pháp 2: Stale-While-Revalidate (SWR)

  • Chúng ta chấp nhận trả về data (Stale) cho 5000 người kia. User không quan tâm giá cũ hơn 1-2 giây.
  • Đồng thời, hệ thống cử 1 background worker đi fetch data MỚI và update vào Cache.
  • Lần request thứ 5001 sẽ thấy data mới.
  • Code (Next.js Cache Header): Cache-Control: s-maxage=60, stale-while-revalidate=30 (Trong 60s đầu: Fresh. Từ 60s-90s: Trả Stale và Fetch ngầm. Sau 90s: Bắt buộc fetch mới).

📝 Ghi nhớ: "Thà ăn cơm nguội còn hơn chết đói (Sập server)."


Nhóm 4: UX & Optimistic UI (Trải nghiệm)

19. Optimistic UI (Giao diện lạc quan) - Phân tích "Deep Dive"

Vấn đề: API likePost mất 2 giây để chạy. Nếu chờ API -> User bấm "Like" -> Đơ 2 giây -> Mới hiện tim đỏ. -> Trải nghiệm quá tệ.

Giải pháp Optimistic:

  1. User bấm Like.
  2. Ngay lập tức tô đỏ trái tim (Fake UI).
  3. Gửi API ngầm bên dưới.
  4. Nếu API lỗi -> Revert (Tô xám lại) và báo lỗi.

Coding Pattern (SWR Mutation):

tsx:
const { data, mutate } = useSWR('/api/post');

const handleLike = async () => {
    const newData = { ...data, isLiked: true };
    
    // 1. Mutate ngay lập tức (Optimistic Update)
    // false = Đừng fetch lại vội, cứ tin tao đi
    mutate(newData, false); 
    
    try {
       // 2. Gọi API thật
       await api.likePost();
    } catch (error) {
       // 3. Nếu lỗi -> Rollback về data cũ
       // (SWR tự động re-fetch data thật từ server để đồng bộ lại)
       mutate();
       toast.error("Like thất bại");
    }
}

React 19 useOptimistic:

tsx:
import { useOptimistic } from 'react';

function LikeButton({ likes }) {
  // optimisticLikes sẽ là UI fake, likes là UI thật
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (state, newItem) => state + 1 // Logic fake: Tăng 1
  );

  return (
    <form action={async () => {
        addOptimisticLike(1); // 1. Update Fake UI ngay
        await toggleLikeAction(); // 2. Gọi Server Action
    }}>
      <button>Like ({optimisticLikes})</button>
    </form>
  );
}

📝 Ghi nhớ: "Lừa dối User một cách có trách nhiệm (Có backup plan khi lỗi)."

20. Scroll Restoration Fail

  • Kịch bản: Đang lướt Feed đến bài thứ 100. Bấm xem chi tiết, rồi bấm Back. Trang Feed load lại từ đầu, văng lên bài số 1. User ức chế xóa app.
  • Nguyên nhân: Data danh sách trong Cache bị mất khi unmount component Feed.
  • Giải pháp: Dùng SWR/React Query (cache lại data cũ) hoặc lưu vị trí scroll vào sessionStorage.
  • 📝 Ghi nhớ: "Tôn trọng vị trí đứng của User."

Nhóm 5: Security (Bảo mật)

25. JWT Expiry Loop (Vòng lặp vô tận)

  • Kịch bản:
    1. Gọi API -> 401 (Hết hạn).
    2. Gọi Refresh Token -> 401 (Refresh token cũng hết hạn/bị thu hồi).
    3. Code logic ngây thơ lại cố gọi lại API ban đầu -> Lặp lại bước 1 -> Loop.
  • Giải pháp: Kiểm tra isRefreshing. Nếu Refresh fail -> Logout thẳng tay (Xóa token client, redirect login).
  • 📝 Ghi nhớ: "Biết dừng đúng lúc khi không còn quyền truy cập."

27. IDOR (Mời ông vào xơi)

  • Kịch bản: Frontend gọi API /api/orders/999 (của người khác). Backend trả về luôn vì chỉ check "đã login" chứ không check "chủ sở hữu".
  • Giải pháp: Backend phải check if (order.userId !== currentUser.id) throw 403.
  • 📝 Ghi nhớ: "Login rồi chưa chắc đã là chủ nhà."

Kết luận: Danh sách này không chỉ để đọc. Hãy in nó ra, dùng làm Checklist khi Review Code cho đồng nghiệp. Bắt được 1 lỗi trong này là bạn đã cứu dự án khỏi 1 ticket Bug khó chịu.

Quảng cáo
mdhorizontal