Skip to content

📅 NGÀY 20: Data Fetching - Advanced Patterns

📍 Phase 2, Tuần 4, Ngày 20 của 45

⏱️ Thời lượng: 3-4 giờ


🎯 Mục tiêu học tập (5 phút)

  • [ ] Giải quyết Race Conditions trong data fetching
  • [ ] Sử dụng AbortController để cancel requests
  • [ ] Implement Dependent Requests (sequential fetching)
  • [ ] Optimize Parallel Requests cho multiple endpoints
  • [ ] Handle Stale Data và request deduplication

🤔 Kiểm tra đầu vào (5 phút)

Trước khi bắt đầu, hãy trả lời 3 câu hỏi sau:

  1. Câu 1: Nếu fetch request đang pending mà component unmount, cần làm gì?

    • Đáp án: Cleanup với isCancelled flag hoặc AbortController (đã học Ngày 18-19)
  2. Câu 2: User typing nhanh "abc" → 3 fetch requests. Request nào nên được dùng?

    • Đáp án: Request cuối cùng ("abc"), cancel 2 requests trước
  3. Câu 3: Fetch user details → Cần userId. Fetch user posts → Cần userId. Làm sao fetch hiệu quả?

    • Đáp án: Chưa biết! (Hôm nay học parallel vs sequential)

📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)

1.1 Vấn Đề Thực Tế: Race Conditions

Scenario: User search với auto-complete

jsx
function UserSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    // ❌ PROBLEM: Race condition!
    async function search() {
      const response = await fetch(`/api/search?q=${query}`);
      const data = await response.json();
      setResults(data); // ← Nào request về trước?
    }

    if (query) search();
  }, [query]);

  // User types: "r" → "re" → "rea" → "reac" → "react"
  // 5 requests fire: Q1, Q2, Q3, Q4, Q5
  // Response order: Q1(100ms), Q3(150ms), Q2(200ms), Q5(250ms), Q4(300ms)
  // ❌ Final results: Q4 ("reac") - WRONG! Should be Q5 ("react")
}

Vấn đề:

  • Multiple requests in-flight cùng lúc
  • Response order KHÔNG đảm bảo = request order
  • Slow request có thể overwrite fast request
  • User sees stale/wrong data

Real-world impact:

  • Search shows outdated results
  • Profile page shows wrong user
  • Dashboard displays mixed data
  • Confusion và poor UX

1.2 Giải Pháp: Request Cancellation & Tracking

Solution 1: Ignore Outdated Responses

jsx
function UserSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    let isLatest = true; // ← Track if this is latest request

    async function search() {
      const response = await fetch(`/api/search?q=${query}`);
      const data = await response.json();

      // ✅ Only update if this is still the latest request
      if (isLatest) {
        setResults(data);
      } else {
        console.log('🚫 Ignoring outdated response for:', query);
      }
    }

    if (query) search();

    // Cleanup: Mark this request as outdated
    return () => {
      isLatest = false;
    };
  }, [query]);
}

Solution 2: Cancel In-Flight Requests (AbortController)

jsx
function UserSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    const controller = new AbortController();

    async function search() {
      try {
        const response = await fetch(`/api/search?q=${query}`, {
          signal: controller.signal, // ← Pass abort signal
        });
        const data = await response.json();
        setResults(data);
      } catch (err) {
        if (err.name === 'AbortError') {
          console.log('🚫 Request cancelled for:', query);
        } else {
          console.error('Error:', err);
        }
      }
    }

    if (query) search();

    // Cleanup: Abort in-flight request
    return () => {
      controller.abort();
    };
  }, [query]);
}

1.3 Mental Model: Request Lifecycle với Cancellation

┌─────────────────────────────────────────────────────────────┐
│         RACE CONDITION & CANCELLATION TIMELINE               │
└─────────────────────────────────────────────────────────────┘

USER TYPES: "r" → "re" → "rea"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

WITHOUT CANCELLATION:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

t=0ms:   Type "r"   → Request R1 fires
t=100ms: Type "re"  → Request R2 fires (R1 still pending)
t=200ms: Type "rea" → Request R3 fires (R1, R2 still pending)
t=250ms: R1 returns → setResults([...]) ✅
t=300ms: R3 returns → setResults([...]) ✅ (Latest data)
t=400ms: R2 returns → setResults([...]) ❌ (Overwrites R3!)

FINAL RESULT: Shows "re" results (WRONG!)

═══════════════════════════════════════════════════════════════

WITH IGNORE OUTDATED:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

t=0ms:   Type "r"   → Request R1, isLatest=true
t=100ms: Type "re"  → Cleanup R1 (isLatest=false), Request R2
t=200ms: Type "rea" → Cleanup R2 (isLatest=false), Request R3
t=250ms: R1 returns → isLatest=false → IGNORE ✅
t=300ms: R3 returns → isLatest=true → UPDATE ✅
t=400ms: R2 returns → isLatest=false → IGNORE ✅

FINAL RESULT: Shows "rea" results (CORRECT!)

═══════════════════════════════════════════════════════════════

WITH ABORTCONTROLLER:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

t=0ms:   Type "r"   → Request R1, controller C1
t=100ms: Type "re"  → Abort C1, Request R2, controller C2
t=200ms: Type "rea" → Abort C2, Request R3, controller C3
t=250ms: R1 aborted → Cancelled (no response)
t=300ms: R3 returns → UPDATE ✅
t=400ms: R2 aborted → Cancelled (no response)

FINAL RESULT: Shows "rea" results (CORRECT!)
BONUS: R1 và R2 actually cancelled → Less network traffic!

═══════════════════════════════════════════════════════════════

KEY DIFFERENCES:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ignore Outdated:
✅ Simple to implement
✅ Works with any async operation
❌ Requests still complete (waste bandwidth)
❌ Server still processes (waste resources)

AbortController:
✅ Actually cancels requests (saves bandwidth)
✅ Server can detect abort (save resources)
✅ Cleaner (no lingering promises)
❌ Only works with fetch (not all async)
❌ Slightly more complex

1.4 Hiểu Lầm Phổ Biến

❌ Hiểu lầm #1: "AbortController cancel cả Promise"

jsx
// ❌ WRONG: Abort không cancel non-fetch promises
useEffect(() => {
  const controller = new AbortController();

  async function doWork() {
    // ❌ setTimeout KHÔNG bị abort!
    await new Promise((resolve) => setTimeout(resolve, 1000));

    // This still runs sau abort
    console.log('This will still execute');
  }

  doWork();

  return () => {
    controller.abort(); // Không effect gì!
  };
}, []);

// ✅ CORRECT: Abort chỉ cho fetch
useEffect(() => {
  const controller = new AbortController();

  async function doWork() {
    // ✅ fetch CÓ THỂ bị abort
    await fetch('/api/data', { signal: controller.signal });
  }

  doWork();

  return () => {
    controller.abort(); // ✅ Cancels fetch
  };
}, []);

❌ Hiểu lầm #2: "Dependencies thay đổi → Old request auto-cancelled"

jsx
// ❌ WRONG ASSUMPTION
function UserDetail({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // ❌ NO! Old fetch still continues!
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then(setUser);
  }, [userId]);
}

// userId: 1 → 2 → 3
// Request 1 fires
// Request 2 fires (Request 1 still going!)
// Request 3 fires (Requests 1, 2 still going!)
// All 3 complete → setUser called 3 times
// ❌ Race condition!

// ✅ CORRECT: Manual cancellation
function UserDetail({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setUser)
      .catch((err) => {
        if (err.name !== 'AbortError') throw err;
      });

    return () => controller.abort(); // ✅ Cancel old
  }, [userId]);
}

❌ Hiểu lầm #3: "Parallel requests = Promise.all() luôn luôn"

jsx
// ❌ WRONG: Promise.all fails nếu 1 request fails
async function fetchDashboard() {
  const [users, posts, comments] = await Promise.all([
    fetch('/api/users'),
    fetch('/api/posts'),
    fetch('/api/comments'), // ← Nếu fail → ALL fail!
  ]);
}

// ✅ BETTER: Promise.allSettled() - handle failures gracefully
async function fetchDashboard() {
  const results = await Promise.allSettled([
    fetch('/api/users').then((r) => r.json()),
    fetch('/api/posts').then((r) => r.json()),
    fetch('/api/comments').then((r) => r.json()),
  ]);

  results.forEach((result, i) => {
    if (result.status === 'fulfilled') {
      console.log(`Request ${i} success:`, result.value);
    } else {
      console.error(`Request ${i} failed:`, result.reason);
    }
  });
}

💻 PHẦN 2: LIVE CODING (45 phút)

Demo 1: AbortController Basic ⭐

jsx
/**
 * Demo: Cancel fetch requests với AbortController
 * Concepts: abort signal, cleanup, error handling
 */

import { useState, useEffect } from 'react';

function AbortControllerDemo() {
  const [userId, setUserId] = useState(1);
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [requestLog, setRequestLog] = useState([]);

  useEffect(() => {
    // Create AbortController for this effect run
    const controller = new AbortController();
    const requestId = `User ${userId} @ ${Date.now()}`;

    console.log(`🚀 Starting request: ${requestId}`);
    setRequestLog((prev) => [...prev, { id: requestId, status: 'pending' }]);

    setLoading(true);
    setError(null);

    async function fetchUser() {
      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          {
            signal: controller.signal, // ← Pass abort signal
          },
        );

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const data = await response.json();

        console.log(`✅ Completed request: ${requestId}`);
        setRequestLog((prev) =>
          prev.map((req) =>
            req.id === requestId ? { ...req, status: 'completed' } : req,
          ),
        );

        setUser(data);
        setLoading(false);
      } catch (err) {
        if (err.name === 'AbortError') {
          console.log(`🚫 Aborted request: ${requestId}`);
          setRequestLog((prev) =>
            prev.map((req) =>
              req.id === requestId ? { ...req, status: 'aborted' } : req,
            ),
          );
        } else {
          console.error(`❌ Failed request: ${requestId}`, err);
          setRequestLog((prev) =>
            prev.map((req) =>
              req.id === requestId ? { ...req, status: 'failed' } : req,
            ),
          );
          setError(err.message);
          setLoading(false);
        }
      }
    }

    fetchUser();

    // Cleanup: Abort request if effect re-runs or unmounts
    return () => {
      console.log(`🧹 Cleanup: Aborting ${requestId}`);
      controller.abort();
    };
  }, [userId]);

  const handleQuickSwitch = () => {
    // Rapidly change userId to demonstrate abortion
    setUserId(1);
    setTimeout(() => setUserId(2), 100);
    setTimeout(() => setUserId(3), 200);
    setTimeout(() => setUserId(4), 300);
  };

  return (
    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
      <h2>AbortController Demo</h2>

      {/* User Selection */}
      <div style={{ marginBottom: '20px' }}>
        <label>Select User: </label>
        <select
          value={userId}
          onChange={(e) => setUserId(Number(e.target.value))}
          style={{ padding: '8px', marginRight: '10px' }}
        >
          {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((id) => (
            <option
              key={id}
              value={id}
            >
              User {id}
            </option>
          ))}
        </select>

        <button
          onClick={handleQuickSwitch}
          style={{
            padding: '8px 16px',
            background: '#ff9800',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          🚀 Quick Switch Test
        </button>
      </div>

      {/* User Display */}
      <div
        style={{
          padding: '20px',
          border: '2px solid #ddd',
          borderRadius: '8px',
          background: 'white',
          minHeight: '150px',
        }}
      >
        {loading && (
          <div style={{ textAlign: 'center', padding: '40px' }}>
            <div style={{ fontSize: '32px' }}>⏳</div>
            <p>Loading user {userId}...</p>
          </div>
        )}

        {error && (
          <div style={{ color: 'red', textAlign: 'center', padding: '40px' }}>
            <div style={{ fontSize: '32px' }}>❌</div>
            <p>Error: {error}</p>
          </div>
        )}

        {!loading && !error && user && (
          <div>
            <h3>{user.name}</h3>
            <p>
              <strong>Email:</strong> {user.email}
            </p>
            <p>
              <strong>Phone:</strong> {user.phone}
            </p>
            <p>
              <strong>Website:</strong> {user.website}
            </p>
          </div>
        )}
      </div>

      {/* Request Log */}
      <div style={{ marginTop: '30px' }}>
        <h3>📋 Request Log (Last 10):</h3>
        <div
          style={{
            background: '#f5f5f5',
            padding: '15px',
            borderRadius: '4px',
            maxHeight: '200px',
            overflowY: 'auto',
          }}
        >
          {requestLog
            .slice(-10)
            .reverse()
            .map((req, i) => (
              <div
                key={i}
                style={{
                  padding: '8px',
                  marginBottom: '5px',
                  background: 'white',
                  borderRadius: '4px',
                  borderLeft: `4px solid ${
                    req.status === 'completed'
                      ? '#4CAF50'
                      : req.status === 'aborted'
                        ? '#ff9800'
                        : req.status === 'failed'
                          ? '#f44336'
                          : '#2196F3'
                  }`,
                }}
              >
                <span style={{ marginRight: '10px' }}>
                  {req.status === 'completed'
                    ? '✅'
                    : req.status === 'aborted'
                      ? '🚫'
                      : req.status === 'failed'
                        ? '❌'
                        : '⏳'}
                </span>
                <span>{req.id}</span>
                <span
                  style={{
                    marginLeft: '10px',
                    fontSize: '12px',
                    color: '#666',
                  }}
                >
                  ({req.status})
                </span>
              </div>
            ))}
        </div>
      </div>

      {/* Instructions */}
      <div
        style={{
          marginTop: '30px',
          padding: '20px',
          background: '#e3f2fd',
          borderRadius: '8px',
        }}
      >
        <h3>🧪 Test Scenarios:</h3>
        <ol>
          <li>
            <strong>Normal fetch:</strong> Select different users slowly
            <br />→ Each request completes ✅
          </li>
          <li>
            <strong>Quick switching:</strong> Change users rapidly
            <br />→ Old requests aborted 🚫, only latest completes ✅
          </li>
          <li>
            <strong>Quick Switch button:</strong> Click button
            <br />
            → Fires 4 requests in 300ms
            <br />→ First 3 aborted, only last one completes
          </li>
        </ol>

        <h3>💡 Key Observations:</h3>
        <ul>
          <li>✅ AbortController created PER effect run</li>
          <li>✅ Cleanup aborts old request BEFORE new request starts</li>
          <li>✅ AbortError caught separately (không display error UI)</li>
          <li>✅ Request log shows lifecycle clearly</li>
        </ul>
      </div>
    </div>
  );
}

export default AbortControllerDemo;

Demo 2: Dependent Requests (Sequential) ⭐⭐

jsx
/**
 * Demo: Sequential data fetching - request B depends on request A
 * Use case: Fetch user → Fetch user's posts
 */

import { useState, useEffect } from 'react';

const USERS_API = 'https://jsonplaceholder.typicode.com/users';
const POSTS_API = 'https://jsonplaceholder.typicode.com/posts';

function DependentRequestsDemo() {
  const [userId, setUserId] = useState(1);

  // User data
  const [user, setUser] = useState(null);
  const [userLoading, setUserLoading] = useState(true);
  const [userError, setUserError] = useState(null);

  // Posts data
  const [posts, setPosts] = useState([]);
  const [postsLoading, setPostsLoading] = useState(false);
  const [postsError, setPostsError] = useState(null);

  // Step 1: Fetch User
  useEffect(() => {
    const controller = new AbortController();

    async function fetchUser() {
      try {
        console.log(`📥 Step 1: Fetching user ${userId}...`);
        setUserLoading(true);
        setUserError(null);

        const response = await fetch(`${USERS_API}/${userId}`, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error('User not found');
        }

        const data = await response.json();
        console.log(`✅ Step 1 Complete: User loaded`);

        setUser(data);
        setUserLoading(false);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error(`❌ Step 1 Failed:`, err);
          setUserError(err.message);
          setUserLoading(false);
        }
      }
    }

    fetchUser();

    return () => controller.abort();
  }, [userId]);

  // Step 2: Fetch Posts (depends on user)
  useEffect(() => {
    // ⚠️ Don't fetch if no user or user still loading
    if (!user || userLoading) return;

    const controller = new AbortController();

    async function fetchPosts() {
      try {
        console.log(`📥 Step 2: Fetching posts for user ${user.id}...`);
        setPostsLoading(true);
        setPostsError(null);

        const response = await fetch(`${POSTS_API}?userId=${user.id}`, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error('Failed to fetch posts');
        }

        const data = await response.json();
        console.log(`✅ Step 2 Complete: ${data.length} posts loaded`);

        setPosts(data);
        setPostsLoading(false);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error(`❌ Step 2 Failed:`, err);
          setPostsError(err.message);
          setPostsLoading(false);
        }
      }
    }

    fetchPosts();

    return () => controller.abort();
  }, [user, userLoading]); // ← Dependencies: user data

  return (
    <div style={{ maxWidth: '1000px', margin: '0 auto', padding: '20px' }}>
      <h2>Dependent Requests Demo</h2>

      {/* User Selection */}
      <div style={{ marginBottom: '20px' }}>
        <label>Select User: </label>
        <select
          value={userId}
          onChange={(e) => setUserId(Number(e.target.value))}
          style={{ padding: '8px' }}
        >
          {[1, 2, 3, 4, 5].map((id) => (
            <option
              key={id}
              value={id}
            >
              User {id}
            </option>
          ))}
        </select>
      </div>

      {/* Request Flow Visualization */}
      <div
        style={{
          padding: '20px',
          background: '#f5f5f5',
          borderRadius: '8px',
          marginBottom: '20px',
        }}
      >
        <h3>🔄 Request Flow:</h3>
        <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
          {/* Step 1 */}
          <div
            style={{
              padding: '15px',
              background: userLoading
                ? '#2196F3'
                : userError
                  ? '#f44336'
                  : '#4CAF50',
              color: 'white',
              borderRadius: '8px',
              flex: 1,
              textAlign: 'center',
            }}
          >
            <div style={{ fontSize: '24px', marginBottom: '5px' }}>
              {userLoading ? '⏳' : userError ? '❌' : '✅'}
            </div>
            <div>Step 1: Fetch User</div>
            <div style={{ fontSize: '12px', marginTop: '5px' }}>
              {userLoading ? 'Loading...' : userError ? 'Failed' : 'Complete'}
            </div>
          </div>

          <div style={{ fontSize: '24px' }}>→</div>

          {/* Step 2 */}
          <div
            style={{
              padding: '15px',
              background: !user
                ? '#ccc'
                : postsLoading
                  ? '#2196F3'
                  : postsError
                    ? '#f44336'
                    : '#4CAF50',
              color: 'white',
              borderRadius: '8px',
              flex: 1,
              textAlign: 'center',
            }}
          >
            <div style={{ fontSize: '24px', marginBottom: '5px' }}>
              {!user ? '⏸️' : postsLoading ? '⏳' : postsError ? '❌' : '✅'}
            </div>
            <div>Step 2: Fetch Posts</div>
            <div style={{ fontSize: '12px', marginTop: '5px' }}>
              {!user
                ? 'Waiting...'
                : postsLoading
                  ? 'Loading...'
                  : postsError
                    ? 'Failed'
                    : 'Complete'}
            </div>
          </div>
        </div>
      </div>

      {/* Content Grid */}
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: '1fr 2fr',
          gap: '20px',
        }}
      >
        {/* User Panel */}
        <div>
          <h3>👤 User Details</h3>
          <div
            style={{
              padding: '15px',
              border: '2px solid #ddd',
              borderRadius: '8px',
              background: 'white',
              minHeight: '150px',
            }}
          >
            {userLoading && <div>Loading user...</div>}
            {userError && (
              <div style={{ color: 'red' }}>Error: {userError}</div>
            )}
            {user && !userLoading && (
              <div>
                <h4>{user.name}</h4>
                <p style={{ fontSize: '14px', color: '#666' }}>
                  📧 {user.email}
                </p>
                <p style={{ fontSize: '14px', color: '#666' }}>
                  🏢 {user.company.name}
                </p>
              </div>
            )}
          </div>
        </div>

        {/* Posts Panel */}
        <div>
          <h3>📝 User Posts ({posts.length})</h3>
          <div
            style={{
              padding: '15px',
              border: '2px solid #ddd',
              borderRadius: '8px',
              background: 'white',
              minHeight: '150px',
              maxHeight: '400px',
              overflowY: 'auto',
            }}
          >
            {!user && (
              <div
                style={{ textAlign: 'center', padding: '40px', color: '#999' }}
              >
                Select a user to view posts
              </div>
            )}

            {user && postsLoading && (
              <div style={{ textAlign: 'center', padding: '40px' }}>
                Loading posts...
              </div>
            )}

            {user && postsError && (
              <div style={{ color: 'red' }}>Error: {postsError}</div>
            )}

            {user && !postsLoading && !postsError && (
              <div style={{ display: 'grid', gap: '10px' }}>
                {posts.map((post) => (
                  <div
                    key={post.id}
                    style={{
                      padding: '10px',
                      border: '1px solid #eee',
                      borderRadius: '4px',
                      background: '#f9f9f9',
                    }}
                  >
                    <h5 style={{ margin: '0 0 5px 0' }}>{post.title}</h5>
                    <p style={{ margin: 0, fontSize: '13px', color: '#666' }}>
                      {post.body.substring(0, 80)}...
                    </p>
                  </div>
                ))}
              </div>
            )}
          </div>
        </div>
      </div>

      {/* Technical Explanation */}
      <div
        style={{
          marginTop: '30px',
          padding: '20px',
          background: '#f0f0f0',
          borderRadius: '8px',
        }}
      >
        <h3>🔑 Key Patterns:</h3>

        <h4>1. Sequential Execution:</h4>
        <pre
          style={{
            background: 'white',
            padding: '10px',
            borderRadius: '4px',
            overflow: 'auto',
          }}
        >
          {`// Effect 1: Fetch user
useEffect(() => {
  fetchUser(userId);
}, [userId]);

// Effect 2: Fetch posts (waits for user)
useEffect(() => {
  if (!user || userLoading) return; // ← Guard clause
  fetchPosts(user.id);
}, [user, userLoading]); // ← Depends on user`}
        </pre>

        <h4>2. Guard Clauses:</h4>
        <ul>
          <li>
            <code>if (!user) return;</code> - Don't fetch if dependency not
            ready
          </li>
          <li>
            <code>if (userLoading) return;</code> - Wait for first request to
            complete
          </li>
        </ul>

        <h4>3. Benefits:</h4>
        <ul>
          <li>✅ Clear separation of concerns</li>
          <li>✅ Each effect handles 1 data source</li>
          <li>✅ Automatic refetch when dependencies change</li>
          <li>✅ Independent error handling</li>
        </ul>
      </div>
    </div>
  );
}

export default DependentRequestsDemo;

Demo 3: Parallel Requests Optimization ⭐⭐⭐

jsx
/**
 * Demo: Parallel data fetching - multiple independent requests
 * Use case: Dashboard with multiple data sources
 */

import { useState, useEffect } from 'react';

const API_BASE = 'https://jsonplaceholder.typicode.com';

function ParallelRequestsDemo() {
  const [refreshTrigger, setRefreshTrigger] = useState(0);

  // Individual states for each data source
  const [stats, setStats] = useState({
    users: { data: null, loading: true, error: null },
    posts: { data: null, loading: true, error: null },
    comments: { data: null, loading: true, error: null },
  });

  // Timing metrics
  const [metrics, setMetrics] = useState({
    startTime: null,
    endTime: null,
    duration: null,
  });

  useEffect(() => {
    const startTime = Date.now();
    setMetrics({ startTime, endTime: null, duration: null });

    console.log('🚀 Starting parallel requests...');

    // Create abort controllers for each request
    const controllers = {
      users: new AbortController(),
      posts: new AbortController(),
      comments: new AbortController(),
    };

    // Fetch function for each endpoint
    async function fetchData(endpoint, key, controller) {
      try {
        console.log(`📥 Fetching ${key}...`);

        setStats((prev) => ({
          ...prev,
          [key]: { ...prev[key], loading: true, error: null },
        }));

        const response = await fetch(`${API_BASE}/${endpoint}`, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const data = await response.json();
        console.log(`✅ ${key} loaded: ${data.length} items`);

        setStats((prev) => ({
          ...prev,
          [key]: { data, loading: false, error: null },
        }));

        return { key, success: true, count: data.length };
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error(`❌ ${key} failed:`, err);

          setStats((prev) => ({
            ...prev,
            [key]: { data: null, loading: false, error: err.message },
          }));

          return { key, success: false, error: err.message };
        }
        return { key, success: false, aborted: true };
      }
    }

    // Fire all requests in parallel
    Promise.allSettled([
      fetchData('users', 'users', controllers.users),
      fetchData('posts', 'posts', controllers.posts),
      fetchData('comments', 'comments', controllers.comments),
    ]).then((results) => {
      const endTime = Date.now();
      const duration = endTime - startTime;

      setMetrics({ startTime, endTime, duration });

      console.log('🏁 All requests completed');
      console.log('⏱️ Total time:', duration, 'ms');

      results.forEach((result, i) => {
        if (result.status === 'fulfilled' && result.value.success) {
          console.log(`  ✅ ${result.value.key}: ${result.value.count} items`);
        } else if (result.status === 'fulfilled' && result.value.aborted) {
          console.log(`  🚫 ${result.value.key}: Aborted`);
        } else {
          console.log(`  ❌ ${result.value?.key || 'Unknown'}: Failed`);
        }
      });
    });

    // Cleanup: Abort all requests
    return () => {
      console.log('🧹 Cleanup: Aborting all requests');
      Object.values(controllers).forEach((ctrl) => ctrl.abort());
    };
  }, [refreshTrigger]);

  const handleRefresh = () => {
    setRefreshTrigger((prev) => prev + 1);
  };

  // Check overall status
  const allLoaded = Object.values(stats).every((s) => !s.loading);
  const anyError = Object.values(stats).some((s) => s.error);
  const allSuccess = Object.values(stats).every((s) => s.data && !s.error);

  return (
    <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          marginBottom: '20px',
        }}
      >
        <h2>Parallel Requests Demo</h2>

        <button
          onClick={handleRefresh}
          disabled={!allLoaded}
          style={{
            padding: '10px 20px',
            background: allLoaded ? '#4CAF50' : '#ccc',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: allLoaded ? 'pointer' : 'not-allowed',
            fontSize: '16px',
          }}
        >
          🔄 Refresh All
        </button>
      </div>

      {/* Performance Metrics */}
      <div
        style={{
          padding: '20px',
          background: allSuccess ? '#d4edda' : anyError ? '#fff3cd' : '#cfe2ff',
          border: `2px solid ${allSuccess ? '#4CAF50' : anyError ? '#ffc107' : '#2196F3'}`,
          borderRadius: '8px',
          marginBottom: '20px',
        }}
      >
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
          }}
        >
          <div>
            <strong>Status:</strong>{' '}
            {!allLoaded
              ? '⏳ Loading...'
              : allSuccess
                ? '✅ All loaded successfully'
                : '⚠️ Some requests failed'}
          </div>

          {metrics.duration && (
            <div>
              <strong>Total Time:</strong> {metrics.duration}ms
            </div>
          )}
        </div>

        {allLoaded && (
          <div style={{ marginTop: '10px', fontSize: '14px', color: '#666' }}>
            💡 Parallel fetching completed in {metrics.duration}ms (vs ~
            {Object.keys(stats).length * 500}ms if sequential)
          </div>
        )}
      </div>

      {/* Stats Cards */}
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
          gap: '20px',
          marginBottom: '30px',
        }}
      >
        {Object.entries(stats).map(([key, state]) => (
          <StatCard
            key={key}
            title={key.charAt(0).toUpperCase() + key.slice(1)}
            data={state.data}
            loading={state.loading}
            error={state.error}
            icon={key === 'users' ? '👥' : key === 'posts' ? '📝' : '💬'}
          />
        ))}
      </div>

      {/* Comparison: Sequential vs Parallel */}
      <div
        style={{
          padding: '20px',
          background: '#f0f0f0',
          borderRadius: '8px',
        }}
      >
        <h3>📊 Sequential vs Parallel Comparison:</h3>

        <div
          style={{
            display: 'grid',
            gridTemplateColumns: '1fr 1fr',
            gap: '20px',
            marginTop: '15px',
          }}
        >
          {/* Sequential */}
          <div
            style={{
              padding: '15px',
              background: 'white',
              borderRadius: '4px',
              border: '2px solid #f44336',
            }}
          >
            <h4 style={{ marginTop: 0 }}>❌ Sequential (Bad)</h4>
            <pre
              style={{
                fontSize: '12px',
                background: '#f9f9f9',
                padding: '10px',
                borderRadius: '4px',
              }}
            >
              {`useEffect(() => {
  // Request 1
  const users = await fetch('/users');
  setUsers(users);
  
  // Request 2 (waits for 1)
  const posts = await fetch('/posts');
  setPosts(posts);
  
  // Request 3 (waits for 2)
  const comments = await fetch('/comments');
  setComments(comments);
}, []);

// Total time: ~1500ms
// (500ms + 500ms + 500ms)`}
            </pre>
          </div>

          {/* Parallel */}
          <div
            style={{
              padding: '15px',
              background: 'white',
              borderRadius: '4px',
              border: '2px solid #4CAF50',
            }}
          >
            <h4 style={{ marginTop: 0 }}>✅ Parallel (Good)</h4>
            <pre
              style={{
                fontSize: '12px',
                background: '#f9f9f9',
                padding: '10px',
                borderRadius: '4px',
              }}
            >
              {`useEffect(() => {
  Promise.allSettled([
    fetch('/users'),
    fetch('/posts'),
    fetch('/comments')
  ]).then(results => {
    // Process results
  });
}, []);

// Total time: ~500ms
// (all fire simultaneously!)
// 3x faster! 🚀`}
            </pre>
          </div>
        </div>

        <div style={{ marginTop: '20px' }}>
          <h4>✅ Benefits of Parallel Fetching:</h4>
          <ul>
            <li>
              <strong>Performance:</strong> Requests fire simultaneously →
              Faster overall
            </li>
            <li>
              <strong>Independence:</strong> One failure doesn't block others
              (Promise.allSettled)
            </li>
            <li>
              <strong>Progressive display:</strong> Show data as it arrives
            </li>
            <li>
              <strong>Better UX:</strong> User sees content loading
              progressively
            </li>
          </ul>

          <h4>⚠️ When NOT to use Parallel:</h4>
          <ul>
            <li>Request B depends on data from Request A (use sequential)</li>
            <li>Server rate limits (may need to throttle)</li>
            <li>Requests must complete in specific order</li>
          </ul>
        </div>
      </div>
    </div>
  );
}

// Reusable StatCard component
function StatCard({ title, data, loading, error, icon }) {
  if (loading) {
    return (
      <div
        style={{
          padding: '20px',
          background: '#f5f5f5',
          borderRadius: '8px',
          textAlign: 'center',
          border: '2px solid #2196F3',
        }}
      >
        <div style={{ fontSize: '32px', marginBottom: '10px' }}>⏳</div>
        <div>Loading {title.toLowerCase()}...</div>
      </div>
    );
  }

  if (error) {
    return (
      <div
        style={{
          padding: '20px',
          background: '#fff0f0',
          borderRadius: '8px',
          textAlign: 'center',
          border: '2px solid #f44336',
        }}
      >
        <div style={{ fontSize: '32px', marginBottom: '10px' }}>❌</div>
        <div style={{ color: '#f44336', marginBottom: '10px' }}>{error}</div>
      </div>
    );
  }

  return (
    <div
      style={{
        padding: '20px',
        background: 'white',
        borderRadius: '8px',
        textAlign: 'center',
        border: '2px solid #4CAF50',
      }}
    >
      <div style={{ fontSize: '32px', marginBottom: '10px' }}>{icon}</div>
      <div style={{ fontSize: '14px', color: '#666', marginBottom: '5px' }}>
        {title}
      </div>
      <div style={{ fontSize: '28px', fontWeight: 'bold', color: '#4CAF50' }}>
        {Array.isArray(data) ? data.length : data}
      </div>
    </div>
  );
}

export default ParallelRequestsDemo;

🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)

⭐ Level 1: Áp Dụng Concept (15 phút)

jsx
/**
 * 🎯 Mục tiêu: Implement AbortController cho search
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: Libraries, custom hooks
 *
 * Requirements:
 * 1. Search input với auto-complete
 * 2. Fetch results khi user types
 * 3. Cancel old requests khi query thay đổi
 * 4. Handle AbortError properly
 *
 * API: https://jsonplaceholder.typicode.com/users?name_like={query}
 *
 * 💡 Gợi ý:
 * - AbortController per effect
 * - Cleanup aborts controller
 * - Catch AbortError separately
 */

// 🎯 NHIỆM VỤ CỦA BẠN:
function SearchWithAbort() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!query || query.length < 2) {
      setResults([]);
      return;
    }

    // TODO: Create AbortController

    // TODO: Fetch users with abort signal

    // TODO: Handle AbortError vs other errors

    // TODO: Return cleanup function that aborts
  }, [query]);

  return (
    <div>
      <h2>Search with Abort</h2>

      <input
        type='text'
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='Search users (min 2 chars)...'
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          border: '2px solid #ddd',
          borderRadius: '4px',
        }}
      />

      {loading && <p>Searching...</p>}
      {error && <p style={{ color: 'red' }}>Error: {error}</p>}

      <div style={{ marginTop: '20px' }}>
        {results.map((user) => (
          <div
            key={user.id}
            style={{
              padding: '10px',
              border: '1px solid #ddd',
              borderRadius: '4px',
              marginBottom: '10px',
            }}
          >
            <strong>{user.name}</strong>
            <p style={{ margin: '5px 0 0 0', fontSize: '14px', color: '#666' }}>
              {user.email}
            </p>
          </div>
        ))}
      </div>

      <div
        style={{ marginTop: '20px', padding: '10px', background: '#f0f0f0' }}
      >
        <h3>🧪 Test:</h3>
        <ol>
          <li>Type "Le" quickly → Should cancel first request</li>
          <li>Type "Leanne" fast → Multiple cancellations</li>
          <li>Console should show abort messages</li>
          <li>No errors displayed for AbortError</li>
        </ol>
      </div>
    </div>
  );
}

export default SearchWithAbort;
💡 Solution
jsx
/**
 * SearchWithAbort - Level 1
 * Implement search with AbortController to cancel outdated requests
 */
function SearchWithAbort() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!query || query.length < 2) {
      setResults([]);
      setLoading(false);
      return;
    }

    const controller = new AbortController();

    async function searchUsers() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/users?name_like=${query}`,
          {
            signal: controller.signal,
          },
        );

        if (!response.ok) {
          throw new Error('Network response was not ok');
        }

        const data = await response.json();

        // Only update state if not aborted
        if (!controller.signal.aborted) {
          setResults(data);
          setLoading(false);
        }
      } catch (err) {
        if (err.name === 'AbortError') {
          // Silent abort - no error message needed
          console.log('Search aborted for query:', query);
        } else {
          setError(err.message || 'Failed to fetch users');
          setLoading(false);
        }
      }
    }

    searchUsers();

    // Cleanup: abort previous request when query changes or component unmounts
    return () => {
      controller.abort();
    };
  }, [query]);

  return (
    <div>
      <h2>Search with Abort</h2>
      <input
        type='text'
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='Search users (min 2 chars)...'
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          border: '2px solid #ddd',
          borderRadius: '4px',
        }}
      />
      {loading && <p>Searching...</p>}
      {error && <p style={{ color: 'red' }}>Error: {error}</p>}
      <div style={{ marginTop: '20px' }}>
        {results.map((user) => (
          <div
            key={user.id}
            style={{
              padding: '10px',
              border: '1px solid #ddd',
              borderRadius: '4px',
              marginBottom: '10px',
            }}
          >
            <strong>{user.name}</strong>
            <p style={{ margin: '5px 0 0 0', fontSize: '14px', color: '#666' }}>
              {user.email}
            </p>
          </div>
        ))}
      </div>
      <div
        style={{ marginTop: '20px', padding: '10px', background: '#f0f0f0' }}
      >
        <h3>🧪 Test:</h3>
        <ol>
          <li>Type "Le" quickly → Should cancel first request</li>
          <li>Type "Leanne" fast → Multiple cancellations</li>
          <li>Console should show abort messages</li>
          <li>No errors displayed for AbortError</li>
        </ol>
      </div>
    </div>
  );
}

/* Kết quả ví dụ khi test:
- Gõ nhanh "Le" → "Lea" → "Leann" → "Leanne"
→ Chỉ request cuối cùng hiển thị (thường là Leanne Graham)
→ Các request trước bị abort → console thấy "Search aborted for query: Lea"...
→ Không bị hiện nhầm tên người dùng cũ
*/

⭐⭐ Level 2: Nhận Biết Pattern (25 phút)

jsx
/**
 * 🎯 Mục tiêu: Dependent requests với proper error handling
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario: Fetch album → Fetch photos from that album
 *
 * Requirements:
 * - Fetch album details
 * - Fetch photos for that album (depends on album ID)
 * - Show loading states for each step
 * - Handle errors independently
 * - Cancel both on album change
 *
 * APIs:
 * - Albums: https://jsonplaceholder.typicode.com/albums/{id}
 * - Photos: https://jsonplaceholder.typicode.com/photos?albumId={id}
 */

function AlbumPhotosViewer() {
  const [albumId, setAlbumId] = useState(1);

  // TODO: Album state
  const [album, setAlbum] = useState(null);
  const [albumLoading, setAlbumLoading] = useState(true);
  const [albumError, setAlbumError] = useState(null);

  // TODO: Photos state
  const [photos, setPhotos] = useState([]);
  const [photosLoading, setPhotosLoading] = useState(false);
  const [photosError, setPhotosError] = useState(null);

  // TODO: Effect 1 - Fetch album
  useEffect(() => {
    const controller = new AbortController();

    async function fetchAlbum() {
      try {
        // TODO: Implement fetch album
        // Set loading, fetch, handle response, set data
      } catch (err) {
        // TODO: Handle abort vs other errors
      }
    }

    fetchAlbum();

    return () => controller.abort();
  }, [albumId]);

  // TODO: Effect 2 - Fetch photos (depends on album)
  useEffect(() => {
    // TODO: Guard clause - don't fetch if no album

    const controller = new AbortController();

    async function fetchPhotos() {
      try {
        // TODO: Implement fetch photos
        // API: /photos?albumId=${album.id}
      } catch (err) {
        // TODO: Handle errors
      }
    }

    fetchPhotos();

    return () => controller.abort();
  }, [album]); // Dependencies: album data

  return (
    <div style={{ maxWidth: '1000px', margin: '0 auto', padding: '20px' }}>
      <h2>Album Photos Viewer</h2>

      {/* Album Selection */}
      <div style={{ marginBottom: '20px' }}>
        <label>Select Album: </label>
        <select
          value={albumId}
          onChange={(e) => setAlbumId(Number(e.target.value))}
        >
          {[1, 2, 3, 4, 5].map((id) => (
            <option
              key={id}
              value={id}
            >
              Album {id}
            </option>
          ))}
        </select>
      </div>

      {/* Request Flow Indicator */}
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: '10px',
          padding: '15px',
          background: '#f5f5f5',
          borderRadius: '8px',
          marginBottom: '20px',
        }}
      >
        <div
          style={{
            padding: '10px 20px',
            background: albumLoading
              ? '#2196F3'
              : albumError
                ? '#f44336'
                : '#4CAF50',
            color: 'white',
            borderRadius: '4px',
            flex: 1,
            textAlign: 'center',
          }}
        >
          {albumLoading
            ? '⏳ Fetching Album'
            : albumError
              ? '❌ Album Failed'
              : '✅ Album Loaded'}
        </div>

        <span style={{ fontSize: '24px' }}>→</span>

        <div
          style={{
            padding: '10px 20px',
            background: !album
              ? '#ccc'
              : photosLoading
                ? '#2196F3'
                : photosError
                  ? '#f44336'
                  : '#4CAF50',
            color: 'white',
            borderRadius: '4px',
            flex: 1,
            textAlign: 'center',
          }}
        >
          {!album
            ? '⏸️ Waiting'
            : photosLoading
              ? '⏳ Fetching Photos'
              : photosError
                ? '❌ Photos Failed'
                : '✅ Photos Loaded'}
        </div>
      </div>

      {/* Content */}
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: '300px 1fr',
          gap: '20px',
        }}
      >
        {/* Album Details */}
        <div>
          <h3>Album Details</h3>
          {/* TODO: Display album info or loading/error */}
        </div>

        {/* Photos Grid */}
        <div>
          <h3>Photos ({photos.length})</h3>
          {/* TODO: Display photos grid or loading/error */}
          {/* Hint: Use thumbnailUrl for images */}
        </div>
      </div>

      <div
        style={{
          marginTop: '30px',
          padding: '15px',
          background: '#e3f2fd',
          borderRadius: '4px',
        }}
      >
        <h3>🔑 Implementation Checklist:</h3>
        <ul>
          <li>[ ] Album fetch has AbortController</li>
          <li>[ ] Photos fetch has AbortController</li>
          <li>[ ] Photos effect has guard clause (if !album return)</li>
          <li>[ ] Both effects cleanup properly</li>
          <li>[ ] AbortError handled separately</li>
          <li>[ ] Loading states independent</li>
          <li>[ ] Changing album cancels both requests</li>
        </ul>
      </div>
    </div>
  );
}

export default AlbumPhotosViewer;
💡 Solution
jsx
/**
 * AlbumPhotosViewer - Level 2
 * Dependent requests: Fetch album → then fetch photos for that album
 * Independent AbortController for each request + proper guard clause
 */
function AlbumPhotosViewer() {
  const [albumId, setAlbumId] = useState(1);

  // Album state
  const [album, setAlbum] = useState(null);
  const [albumLoading, setAlbumLoading] = useState(true);
  const [albumError, setAlbumError] = useState(null);

  // Photos state
  const [photos, setPhotos] = useState([]);
  const [photosLoading, setPhotosLoading] = useState(false);
  const [photosError, setPhotosError] = useState(null);

  // Effect 1: Fetch album details
  useEffect(() => {
    const controller = new AbortController();

    async function fetchAlbum() {
      setAlbumLoading(true);
      setAlbumError(null);
      setPhotos([]); // Reset photos when changing album

      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/albums/${albumId}`,
          { signal: controller.signal },
        );

        if (!response.ok) {
          throw new Error(`Album not found (HTTP ${response.status})`);
        }

        const data = await response.json();
        setAlbum(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setAlbumError(err.message || 'Failed to load album');
        }
      } finally {
        setAlbumLoading(false);
      }
    }

    fetchAlbum();

    return () => controller.abort();
  }, [albumId]);

  // Effect 2: Fetch photos (only when we have a valid album)
  useEffect(() => {
    // Guard clause: don't fetch until album is loaded
    if (!album || albumLoading) return;

    const controller = new AbortController();

    async function fetchPhotos() {
      setPhotosLoading(true);
      setPhotosError(null);

      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/photos?albumId=${album.id}`,
          { signal: controller.signal },
        );

        if (!response.ok) {
          throw new Error('Failed to fetch photos');
        }

        const data = await response.json();
        setPhotos(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setPhotosError(err.message || 'Failed to load photos');
        }
      } finally {
        setPhotosLoading(false);
      }
    }

    fetchPhotos();

    return () => controller.abort();
  }, [album, albumLoading]); // Depend on album object & loading state

  return (
    <div style={{ maxWidth: '1000px', margin: '0 auto', padding: '20px' }}>
      <h2>Album Photos Viewer</h2>

      {/* Album Selection */}
      <div style={{ marginBottom: '20px' }}>
        <label>Select Album: </label>
        <select
          value={albumId}
          onChange={(e) => setAlbumId(Number(e.target.value))}
        >
          {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((id) => (
            <option
              key={id}
              value={id}
            >
              Album {id}
            </option>
          ))}
        </select>
      </div>

      {/* Request Flow Indicator */}
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: '10px',
          padding: '15px',
          background: '#f5f5f5',
          borderRadius: '8px',
          marginBottom: '20px',
        }}
      >
        <div
          style={{
            padding: '10px 20px',
            background: albumLoading
              ? '#2196F3'
              : albumError
                ? '#f44336'
                : '#4CAF50',
            color: 'white',
            borderRadius: '4px',
            flex: 1,
            textAlign: 'center',
          }}
        >
          {albumLoading
            ? '⏳ Fetching Album'
            : albumError
              ? '❌ Album Failed'
              : '✅ Album Loaded'}
        </div>
        <span style={{ fontSize: '24px' }}>→</span>
        <div
          style={{
            padding: '10px 20px',
            background: !album
              ? '#ccc'
              : photosLoading
                ? '#2196F3'
                : photosError
                  ? '#f44336'
                  : '#4CAF50',
            color: 'white',
            borderRadius: '4px',
            flex: 1,
            textAlign: 'center',
          }}
        >
          {!album
            ? '⏸️ Waiting'
            : photosLoading
              ? '⏳ Fetching Photos'
              : photosError
                ? '❌ Photos Failed'
                : '✅ Photos Loaded'}
        </div>
      </div>

      {/* Content */}
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: '300px 1fr',
          gap: '20px',
        }}
      >
        {/* Album Details */}
        <div>
          <h3>Album Details</h3>
          {albumLoading && <div>Loading album info...</div>}
          {albumError && <div style={{ color: 'red' }}>{albumError}</div>}
          {album && !albumLoading && (
            <div
              style={{
                padding: '12px',
                background: '#fff',
                border: '1px solid #eee',
              }}
            >
              <h4>{album.title}</h4>
              <p style={{ color: '#666', fontSize: '14px' }}>
                User ID: {album.userId} • Album ID: {album.id}
              </p>
            </div>
          )}
        </div>

        {/* Photos Grid */}
        <div>
          <h3>Photos ({photos.length})</h3>
          {photosLoading && <div>Loading photos...</div>}
          {photosError && <div style={{ color: 'red' }}>{photosError}</div>}
          {!album && <div style={{ color: '#999' }}>Select an album first</div>}
          {photos.length > 0 && !photosLoading && !photosError && (
            <div
              style={{
                display: 'grid',
                gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
                gap: '12px',
                maxHeight: '500px',
                overflowY: 'auto',
              }}
            >
              {photos.map((photo) => (
                <div
                  key={photo.id}
                  style={{
                    border: '1px solid #eee',
                    borderRadius: '6px',
                    overflow: 'hidden',
                    background: '#fff',
                  }}
                >
                  <img
                    src={photo.thumbnailUrl}
                    alt={photo.title}
                    style={{ width: '100%', height: 'auto', display: 'block' }}
                  />
                  <div style={{ padding: '8px', fontSize: '13px' }}>
                    {photo.title.substring(0, 60)}
                    {photo.title.length > 60 ? '...' : ''}
                  </div>
                </div>
              ))}
            </div>
          )}
        </div>
      </div>

      <div
        style={{
          marginTop: '30px',
          padding: '15px',
          background: '#e3f2fd',
          borderRadius: '4px',
        }}
      >
        <h3>🔑 Implementation Checklist:</h3>
        <ul>
          <li>✓ Album fetch has AbortController</li>
          <li>✓ Photos fetch has AbortController</li>
          <li>✓ Photos effect has guard clause (if !album return)</li>
          <li>✓ Both effects cleanup properly</li>
          <li>✓ AbortError handled separately</li>
          <li>✓ Loading states independent</li>
          <li>✓ Changing album cancels both requests</li>
        </ul>
      </div>
    </div>
  );
}

/* Kết quả ví dụ khi test:
- Chọn Album 1 → thấy album title + ~50 thumbnails load tuần tự
- Chuyển nhanh sang Album 3 → request Album 1 bị abort, request photos Album 1 bị abort
- Chỉ thấy dữ liệu Album 3 + photos của Album 3
- Không bị hiện lẫn lộn ảnh giữa các album
- Console có log abort khi chuyển nhanh
*/

⭐⭐⭐ Level 3: Kịch Bản Thực Tế (40 phút)

jsx
/**
 * 🎯 Mục tiêu: E-commerce Product Browser với Advanced Fetching
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 Product Requirements:
 * User Story: "Là user, tôi muốn browse products by category,
 * search products, và view product details"
 *
 * ✅ Acceptance Criteria:
 * - [ ] Fetch categories on mount
 * - [ ] Fetch products by selected category
 * - [ ] Search products (debounced)
 * - [ ] Click product → Fetch product details
 * - [ ] Cancel old requests khi filter/search thay đổi
 * - [ ] Loading states cho mỗi section
 * - [ ] Error handling graceful
 *
 * 🎨 Technical Constraints:
 * - API: https://fakestoreapi.com
 * - Endpoints:
 *   - /products/categories
 *   - /products/category/{category}
 *   - /products/{id}
 * - AbortController cho tất cả requests
 * - Debounce search 500ms
 *
 * 🚨 Edge Cases:
 * - Switch category while search active → Clear search
 * - Click product while loading → Cancel old, fetch new
 * - Rapid category switching → Only latest request matters
 */

import { useState, useEffect } from 'react';

const API_BASE = 'https://fakestoreapi.com';

function ProductBrowser() {
  // Categories
  const [categories, setCategories] = useState([]);
  const [categoriesLoading, setCategoriesLoading] = useState(true);

  // Products
  const [selectedCategory, setSelectedCategory] = useState('all');
  const [products, setProducts] = useState([]);
  const [productsLoading, setProductsLoading] = useState(false);
  const [productsError, setProductsError] = useState(null);

  // Search
  const [searchQuery, setSearchQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');

  // Product details
  const [selectedProduct, setSelectedProduct] = useState(null);
  const [productDetails, setProductDetails] = useState(null);
  const [detailsLoading, setDetailsLoading] = useState(false);

  // TODO: Effect 1 - Fetch categories (once on mount)
  useEffect(() => {
    const controller = new AbortController();

    async function fetchCategories() {
      try {
        const response = await fetch(`${API_BASE}/products/categories`, {
          signal: controller.signal,
        });
        const data = await response.json();
        setCategories(['all', ...data]);
        setCategoriesLoading(false);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('Categories error:', err);
          setCategoriesLoading(false);
        }
      }
    }

    fetchCategories();
    return () => controller.abort();
  }, []);

  // TODO: Effect 2 - Debounce search
  useEffect(() => {
    const timerId = setTimeout(() => {
      setDebouncedQuery(searchQuery);
    }, 500);

    return () => clearTimeout(timerId);
  }, [searchQuery]);

  // TODO: Effect 3 - Fetch products (by category or search)
  useEffect(() => {
    const controller = new AbortController();

    async function fetchProducts() {
      try {
        setProductsLoading(true);
        setProductsError(null);

        let url;
        if (debouncedQuery) {
          // Search mode - fetch all and filter client-side
          url = `${API_BASE}/products`;
        } else if (selectedCategory === 'all') {
          url = `${API_BASE}/products`;
        } else {
          url = `${API_BASE}/products/category/${selectedCategory}`;
        }

        const response = await fetch(url, { signal: controller.signal });
        let data = await response.json();

        // Client-side filter by search
        if (debouncedQuery) {
          data = data.filter((p) =>
            p.title.toLowerCase().includes(debouncedQuery.toLowerCase()),
          );
        }

        setProducts(data);
        setProductsLoading(false);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setProductsError(err.message);
          setProductsLoading(false);
        }
      }
    }

    fetchProducts();
    return () => controller.abort();
  }, [selectedCategory, debouncedQuery]);

  // TODO: Effect 4 - Fetch product details
  useEffect(() => {
    if (!selectedProduct) {
      setProductDetails(null);
      return;
    }

    const controller = new AbortController();

    async function fetchDetails() {
      try {
        setDetailsLoading(true);

        const response = await fetch(
          `${API_BASE}/products/${selectedProduct}`,
          {
            signal: controller.signal,
          },
        );
        const data = await response.json();

        setProductDetails(data);
        setDetailsLoading(false);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('Details error:', err);
          setDetailsLoading(false);
        }
      }
    }

    fetchDetails();
    return () => controller.abort();
  }, [selectedProduct]);

  const handleCategoryChange = (category) => {
    setSelectedCategory(category);
    setSearchQuery(''); // Clear search when changing category
    setSelectedProduct(null); // Clear selected product
  };

  const handleProductClick = (productId) => {
    setSelectedProduct(productId);
  };

  return (
    <div style={{ maxWidth: '1400px', margin: '0 auto', padding: '20px' }}>
      <h2>🛒 Product Browser</h2>

      {/* Filters Bar */}
      <div
        style={{
          display: 'flex',
          gap: '20px',
          marginBottom: '20px',
          padding: '15px',
          background: '#f5f5f5',
          borderRadius: '8px',
        }}
      >
        {/* Categories */}
        <div style={{ flex: 1 }}>
          <label
            style={{
              display: 'block',
              marginBottom: '5px',
              fontWeight: 'bold',
            }}
          >
            Category:
          </label>
          {categoriesLoading ? (
            <div>Loading categories...</div>
          ) : (
            <select
              value={selectedCategory}
              onChange={(e) => handleCategoryChange(e.target.value)}
              style={{ width: '100%', padding: '8px', borderRadius: '4px' }}
            >
              {categories.map((cat) => (
                <option
                  key={cat}
                  value={cat}
                >
                  {cat.charAt(0).toUpperCase() + cat.slice(1)}
                </option>
              ))}
            </select>
          )}
        </div>

        {/* Search */}
        <div style={{ flex: 2 }}>
          <label
            style={{
              display: 'block',
              marginBottom: '5px',
              fontWeight: 'bold',
            }}
          >
            Search:
          </label>
          <input
            type='text'
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            placeholder='Search products...'
            style={{
              width: '100%',
              padding: '8px',
              borderRadius: '4px',
              border: '1px solid #ddd',
            }}
          />
          {searchQuery && (
            <div style={{ fontSize: '12px', color: '#666', marginTop: '5px' }}>
              {debouncedQuery !== searchQuery
                ? 'Typing...'
                : `Searching for "${debouncedQuery}"`}
            </div>
          )}
        </div>
      </div>

      {/* Main Content */}
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: selectedProduct ? '2fr 1fr' : '1fr',
          gap: '20px',
        }}
      >
        {/* Products Grid */}
        <div>
          <h3>Products ({products.length})</h3>

          {productsLoading ? (
            <div style={{ textAlign: 'center', padding: '40px' }}>
              Loading products...
            </div>
          ) : productsError ? (
            <div style={{ color: 'red', padding: '20px' }}>
              Error: {productsError}
            </div>
          ) : products.length === 0 ? (
            <div
              style={{ textAlign: 'center', padding: '40px', color: '#999' }}
            >
              No products found
            </div>
          ) : (
            <div
              style={{
                display: 'grid',
                gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
                gap: '15px',
              }}
            >
              {products.map((product) => (
                <div
                  key={product.id}
                  onClick={() => handleProductClick(product.id)}
                  style={{
                    padding: '15px',
                    border:
                      selectedProduct === product.id
                        ? '2px solid #4CAF50'
                        : '1px solid #ddd',
                    borderRadius: '8px',
                    cursor: 'pointer',
                    background: 'white',
                    transition: 'all 0.2s',
                  }}
                >
                  <img
                    src={product.image}
                    alt={product.title}
                    style={{
                      width: '100%',
                      height: '150px',
                      objectFit: 'contain',
                      marginBottom: '10px',
                    }}
                  />
                  <h4
                    style={{
                      margin: '0 0 10px 0',
                      fontSize: '14px',
                      height: '40px',
                      overflow: 'hidden',
                    }}
                  >
                    {product.title}
                  </h4>
                  <div
                    style={{
                      display: 'flex',
                      justifyContent: 'space-between',
                      alignItems: 'center',
                    }}
                  >
                    <span style={{ fontWeight: 'bold', color: '#4CAF50' }}>
                      ${product.price}
                    </span>
                    <span style={{ fontSize: '12px', color: '#666' }}>
                      ⭐ {product.rating?.rate || 'N/A'}
                    </span>
                  </div>
                </div>
              ))}
            </div>
          )}
        </div>

        {/* Product Details Panel */}
        {selectedProduct && (
          <div>
            <h3>Product Details</h3>
            <div
              style={{
                padding: '20px',
                border: '2px solid #ddd',
                borderRadius: '8px',
                background: 'white',
                position: 'sticky',
                top: '20px',
              }}
            >
              {detailsLoading ? (
                <div style={{ textAlign: 'center', padding: '40px' }}>
                  Loading details...
                </div>
              ) : productDetails ? (
                <div>
                  <img
                    src={productDetails.image}
                    alt={productDetails.title}
                    style={{
                      width: '100%',
                      height: '200px',
                      objectFit: 'contain',
                      marginBottom: '15px',
                    }}
                  />
                  <h4>{productDetails.title}</h4>
                  <p style={{ color: '#666', fontSize: '14px' }}>
                    {productDetails.description}
                  </p>
                  <div style={{ marginTop: '15px' }}>
                    <div
                      style={{
                        fontSize: '24px',
                        fontWeight: 'bold',
                        color: '#4CAF50',
                        marginBottom: '10px',
                      }}
                    >
                      ${productDetails.price}
                    </div>
                    <div style={{ fontSize: '14px', color: '#666' }}>
                      Category: {productDetails.category}
                    </div>
                    <div style={{ fontSize: '14px', color: '#666' }}>
                      Rating: ⭐ {productDetails.rating?.rate} (
                      {productDetails.rating?.count} reviews)
                    </div>
                  </div>
                  <button
                    onClick={() => setSelectedProduct(null)}
                    style={{
                      marginTop: '15px',
                      padding: '10px 20px',
                      background: '#2196F3',
                      color: 'white',
                      border: 'none',
                      borderRadius: '4px',
                      cursor: 'pointer',
                      width: '100%',
                    }}
                  >
                    Close Details
                  </button>
                </div>
              ) : null}
            </div>
          </div>
        )}
      </div>

      {/* Technical Info */}
      <div
        style={{
          marginTop: '30px',
          padding: '20px',
          background: '#f0f0f0',
          borderRadius: '8px',
        }}
      >
        <h3>🔧 Technical Implementation:</h3>
        <ul>
          <li>✅ 4 independent useEffect hooks</li>
          <li>✅ Each effect has AbortController</li>
          <li>✅ Search debounced 500ms</li>
          <li>✅ Category change clears search</li>
          <li>✅ Parallel category + product fetches cancelled on change</li>
          <li>✅ Product details fetch cancelled on new selection</li>
        </ul>
      </div>
    </div>
  );
}

export default ProductBrowser;
💡 Solution
jsx
/**
 * ProductBrowser - Level 3
 * E-commerce Product Browser với:
 * - Fetch categories on mount
 * - Fetch products by category or search (debounced)
 * - Product details on click
 * - AbortController cho mọi request
 * - Clear search khi đổi category
 */
function ProductBrowser() {
  const [categories, setCategories] = useState([]);
  const [categoriesLoading, setCategoriesLoading] = useState(true);

  const [selectedCategory, setSelectedCategory] = useState('all');
  const [products, setProducts] = useState([]);
  const [productsLoading, setProductsLoading] = useState(false);
  const [productsError, setProductsError] = useState(null);

  const [searchQuery, setSearchQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');

  const [selectedProduct, setSelectedProduct] = useState(null);
  const [productDetails, setProductDetails] = useState(null);
  const [detailsLoading, setDetailsLoading] = useState(false);

  // Effect 1: Fetch categories once on mount
  useEffect(() => {
    const controller = new AbortController();

    async function fetchCategories() {
      try {
        const res = await fetch(`${API_BASE}/products/categories`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error('Failed to fetch categories');
        const data = await res.json();
        setCategories(['all', ...data]);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('Categories fetch failed:', err);
        }
      } finally {
        setCategoriesLoading(false);
      }
    }

    fetchCategories();

    return () => controller.abort();
  }, []);

  // Effect 2: Debounce search input
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(searchQuery.trim());
    }, 500);

    return () => clearTimeout(timer);
  }, [searchQuery]);

  // Effect 3: Fetch products (category hoặc search)
  useEffect(() => {
    const controller = new AbortController();

    async function fetchProducts() {
      setProductsLoading(true);
      setProductsError(null);
      setProducts([]);

      try {
        let endpoint = `${API_BASE}/products`;

        if (selectedCategory !== 'all') {
          endpoint = `${API_BASE}/products/category/${selectedCategory}`;
        }
        const res = await fetch(endpoint, { signal: controller.signal });
        if (!res.ok) throw new Error('Failed to fetch products');

        let data = await res.json();

        // Search mode: lấy tất cả rồi filter client-side
        if (debouncedQuery) {
          const keyword = debouncedQuery.toLowerCase();
          data = data.filter((p) => p.title.toLowerCase().includes(keyword));
        }

        setProducts(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setProductsError(err.message || 'Failed to load products');
        }
      } finally {
        setProductsLoading(false);
      }
    }

    fetchProducts();

    return () => controller.abort();
  }, [selectedCategory, debouncedQuery]);

  // Effect 4: Fetch product details khi chọn sản phẩm
  useEffect(() => {
    if (!selectedProduct) {
      setProductDetails(null);
      setDetailsLoading(false);
      return;
    }

    const controller = new AbortController();

    async function fetchDetails() {
      setDetailsLoading(true);

      try {
        const res = await fetch(`${API_BASE}/products/${selectedProduct}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error('Failed to fetch product details');
        const data = await res.json();
        setProductDetails(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('Product details error:', err);
        }
      } finally {
        setDetailsLoading(false);
      }
    }

    fetchDetails();

    return () => controller.abort();
  }, [selectedProduct]);

  const handleCategoryChange = (category) => {
    setSelectedCategory(category);
    setSearchQuery(''); // Clear search khi đổi category
    setSelectedProduct(null); // Đóng chi tiết sản phẩm
  };

  const handleProductClick = (productId) => {
    setSelectedProduct(productId);
  };

  return (
    <div style={{ maxWidth: '1400px', margin: '0 auto', padding: '20px' }}>
      <h2>🛒 Product Browser</h2>

      {/* Filters */}
      <div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}>
        <div style={{ flex: 1 }}>
          <label
            style={{
              fontWeight: 'bold',
              display: 'block',
              marginBottom: '5px',
            }}
          >
            Category
          </label>
          {categoriesLoading ? (
            <div>Loading categories...</div>
          ) : (
            <select
              value={selectedCategory}
              onChange={(e) => handleCategoryChange(e.target.value)}
              style={{ width: '100%', padding: '8px' }}
            >
              {categories.map((cat) => (
                <option
                  key={cat}
                  value={cat}
                >
                  {cat.charAt(0).toUpperCase() + cat.slice(1)}
                </option>
              ))}
            </select>
          )}
        </div>

        <div style={{ flex: 2 }}>
          <label
            style={{
              fontWeight: 'bold',
              display: 'block',
              marginBottom: '5px',
            }}
          >
            Search
          </label>
          <input
            type='text'
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            placeholder='Search products...'
            style={{ width: '100%', padding: '8px' }}
          />
          {searchQuery && (
            <small style={{ color: '#666' }}>
              {debouncedQuery !== searchQuery
                ? 'Typing...'
                : `Searching "${debouncedQuery}"`}
            </small>
          )}
        </div>
      </div>

      {/* Main content */}
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: selectedProduct ? '2fr 1fr' : '1fr',
          gap: '20px',
        }}
      >
        {/* Products list */}
        <div>
          <h3>Products {products.length > 0 && `(${products.length})`}</h3>

          {productsLoading && (
            <div style={{ padding: '40px', textAlign: 'center' }}>
              Loading products...
            </div>
          )}

          {productsError && (
            <div style={{ color: 'red', padding: '20px' }}>
              Error: {productsError}
            </div>
          )}

          {!productsLoading && !productsError && products.length === 0 && (
            <div
              style={{ textAlign: 'center', color: '#999', padding: '40px' }}
            >
              No products found
            </div>
          )}

          {products.length > 0 && (
            <div
              style={{
                display: 'grid',
                gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
                gap: '15px',
              }}
            >
              {products.map((product) => (
                <div
                  key={product.id}
                  onClick={() => handleProductClick(product.id)}
                  style={{
                    border:
                      selectedProduct === product.id
                        ? '2px solid #4CAF50'
                        : '1px solid #ddd',
                    borderRadius: '8px',
                    padding: '12px',
                    cursor: 'pointer',
                    background: 'white',
                  }}
                >
                  <img
                    src={product.image}
                    alt={product.title}
                    style={{
                      width: '100%',
                      height: '140px',
                      objectFit: 'contain',
                      marginBottom: '10px',
                    }}
                  />
                  <h4
                    style={{
                      fontSize: '14px',
                      margin: '0 0 8px 0',
                      height: '40px',
                      overflow: 'hidden',
                    }}
                  >
                    {product.title}
                  </h4>
                  <div
                    style={{ display: 'flex', justifyContent: 'space-between' }}
                  >
                    <span style={{ fontWeight: 'bold', color: '#4CAF50' }}>
                      ${product.price}
                    </span>
                    <span>⭐ {product.rating?.rate || '?'}</span>
                  </div>
                </div>
              ))}
            </div>
          )}
        </div>

        {/* Product details sidebar */}
        {selectedProduct && (
          <div>
            <h3>Product Details</h3>
            <div
              style={{
                position: 'sticky',
                top: '20px',
                padding: '20px',
                border: '1px solid #ddd',
                borderRadius: '8px',
                background: 'white',
              }}
            >
              {detailsLoading && (
                <div style={{ textAlign: 'center', padding: '60px 0' }}>
                  Loading details...
                </div>
              )}

              {productDetails && !detailsLoading && (
                <>
                  <img
                    src={productDetails.image}
                    alt={productDetails.title}
                    style={{
                      width: '100%',
                      height: '220px',
                      objectFit: 'contain',
                      marginBottom: '15px',
                    }}
                  />
                  <h4>{productDetails.title}</h4>
                  <p style={{ color: '#555', fontSize: '14px' }}>
                    {productDetails.description}
                  </p>
                  <div
                    style={{
                      margin: '15px 0',
                      fontSize: '22px',
                      fontWeight: 'bold',
                      color: '#4CAF50',
                    }}
                  >
                    ${productDetails.price}
                  </div>
                  <div style={{ color: '#666', fontSize: '14px' }}>
                    Category: {productDetails.category}
                    <br />
                    Rating: ⭐ {productDetails.rating?.rate} (
                    {productDetails.rating?.count} reviews)
                  </div>
                  <button
                    onClick={() => setSelectedProduct(null)}
                    style={{
                      marginTop: '20px',
                      width: '100%',
                      padding: '10px',
                      background: '#2196F3',
                      color: 'white',
                      border: 'none',
                      borderRadius: '6px',
                      cursor: 'pointer',
                    }}
                  >
                    Close
                  </button>
                </>
              )}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

/* Kết quả ví dụ khi test:
- Load trang → thấy categories: all, electronics, jewelery, men's clothing, women's clothing
- Chọn "electronics" → load ~5 sản phẩm electronics
- Gõ nhanh "shirt" → debounce 500ms → chỉ hiển thị sản phẩm có "shirt" trong title
- Chuyển sang category "jewelery" → search bị clear, load sản phẩm jewelery
- Click vào một sản phẩm → sidebar phải hiện chi tiết (hình, mô tả, giá, rating)
- Click sản phẩm khác nhanh → request cũ bị abort, chỉ hiện chi tiết sản phẩm mới nhất
- Console thấy thông báo abort khi chuyển category hoặc click sản phẩm nhanh
*/

⭐⭐⭐⭐ Level 4: Quyết Định Kiến Trúc (60 phút)

jsx
/**
 * 🎯 Mục tiêu: Social Media Feed với Infinite Scroll & Real-time Updates
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Research & Design (20 phút)
 *
 * Context:
 * Xây dựng social feed with:
 * - Initial posts load
 * - Infinite scroll (load more)
 * - Pull-to-refresh
 * - Real-time updates (polling)
 * - Cancel all on unmount
 *
 * APPROACH OPTIONS:
 *
 * APPROACH 1: Single effect, all logic together
 * Pros:
 * - One effect, one controller
 * Cons:
 * - Complex, hard to read
 * - Mixing concerns
 * - Difficult to test
 *
 * APPROACH 2: Separate effects per concern
 * Pros:
 * - Clear separation
 * - Easy to disable features
 * - Testable
 * Cons:
 * - Multiple controllers
 * - Coordination needed
 *
 * APPROACH 3: Custom hooks (not yet learned)
 *
 * 💭 RECOMMENDATION: Approach 2
 *
 * ADR:
 * ---
 * # ADR: Social Feed Architecture
 *
 * ## Decision
 * Separate effects for:
 * 1. Initial load
 * 2. Infinite scroll
 * 3. Polling updates
 *
 * ## Rationale
 * - Each concern independent
 * - Can enable/disable features
 * - Clear abort strategy per feature
 * ---
 */

// 💻 PHASE 2: Implementation (30 phút)

// 🧪 PHASE 3: Testing (10 phút)
// - [ ] Initial posts load
// - [ ] Scroll to bottom → Load more
// - [ ] New posts banner appears (polling)
// - [ ] Click banner → Refresh feed
// - [ ] Toggle polling on/off
// - [ ] Refresh button works
// - [ ] Unmount → All requests cancelled
💡 Solution
jsx
import { useEffect, useRef, useState } from 'react';

// API endpoint và hằng số
const POSTS_API = 'https://jsonplaceholder.typicode.com/posts';
const POSTS_PER_PAGE = 10;
const PULL_THRESHOLD = 80;

/**
 * Interface đại diện cho một bài viết từ JSONPlaceholder API
 */
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

/**
 * Component chính hiển thị nguồn cấp mạng xã hội với các tính năng:
 * - Tải bài viết ban đầu
 * - Cuộn vô hạn (infinite scroll) sử dụng Intersection Observer
 * - Kéo xuống để làm mới (pull-to-refresh) trên thiết bị cảm ứng
 * - Polling giả lập để phát hiện bài viết mới
 * - Banner thông báo bài viết mới
 * - Số thứ tự (#1, #2, ...) cho mỗi bài viết để dễ theo dõi infinite load
 *
 * @component
 */
function SocialFeed() {
  // ==================== STATE ====================

  /** Danh sách bài viết đã tải */
  const [posts, setPosts] = useState<Post[]>([]);

  /** Trang hiện tại (dùng cho phân trang API) */
  const [page, setPage] = useState<number>(1);

  /** Còn dữ liệu để tải thêm không */
  const [hasMore, setHasMore] = useState<boolean>(true);

  /** Đang tải dữ liệu ban đầu */
  const [loading, setLoading] = useState<boolean>(true);

  /** Đang tải thêm bài viết (infinite scroll) */
  const [loadingMore, setLoadingMore] = useState<boolean>(false);

  /** Lỗi nếu có khi tải dữ liệu */
  const [error, setError] = useState<string | null>(null);

  /** Key để trigger reload dữ liệu ban đầu khi refresh */
  const [refreshKey, setRefreshKey] = useState<number>(0);

  // Polling state
  /** Bật/tắt chế độ polling tự động */
  const [polling, setPolling] = useState<boolean>(true);

  /** Số bài viết mới (giả lập) */
  const [newPostsCount, setNewPostsCount] = useState<number>(0);

  // Pull-to-refresh state
  /** Khoảng cách kéo xuống hiện tại (px) */
  const [pullDistance, setPullDistance] = useState<number>(0);

  /** Đang trong trạng thái kéo xuống */
  const [pulling, setPulling] = useState<boolean>(false);

  /** Vị trí Y bắt đầu khi chạm màn hình */
  const startYRef = useRef<number | null>(null);

  // ==================== REFS ====================

  /** Ref cho IntersectionObserver target (trigger load more) */
  const observerRef = useRef<IntersectionObserver | null>(null);

  // ==================== CALLBACKS ====================

  /**
   * Callback ref để thiết lập IntersectionObserver cho phần tử trigger load more
   * @param node - Phần tử DOM cần observe
   */
  const setObserverTarget = (node: HTMLDivElement | null) => {
    if (observerRef.current) {
      observerRef.current.disconnect();
      observerRef.current = null;
    }

    if (!node) return;

    observerRef.current = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && hasMore && !loading && !loadingMore) {
          loadMore();
        }
      },
      { threshold: 1 },
    );

    observerRef.current.observe(node);
  };

  // ==================== TOUCH HANDLERS ====================

  /**
   * Xử lý sự kiện touchstart cho pull-to-refresh
   * Chỉ bắt đầu pull khi đang ở đầu trang và không đang load
   */
  const onTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
    if (window.scrollY !== 0 || loading || loadingMore) return;

    startYRef.current = e.touches[0].clientY;
    setPulling(true);
  };

  /**
   * Xử lý sự kiện touchmove – tính toán khoảng cách kéo
   */
  const onTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
    if (!pulling || startYRef.current === null) return;

    const currentY = e.touches[0].clientY;
    const distance = currentY - startYRef.current;

    if (distance > 0) {
      setPullDistance(Math.min(distance, 120));
    }
  };

  /**
   * Xử lý sự kiện touchend – quyết định có refresh hay không
   */
  const onTouchEnd = () => {
    if (!pulling) return;

    if (pullDistance >= PULL_THRESHOLD) {
      handleRefresh();
    }

    setPullDistance(0);
    setPulling(false);
    startYRef.current = null;
  };

  // ==================== DATA FETCHING ====================

  /**
   * Tải danh sách bài viết ban đầu (page 1)
   */
  useEffect(() => {
    const controller = new AbortController();

    async function loadInitialPosts() {
      try {
        setLoading(true);
        setError(null);

        const res = await fetch(
          `${POSTS_API}?_page=1&_limit=${POSTS_PER_PAGE}`,
          { signal: controller.signal },
        );

        if (!res.ok) throw new Error('Network error');

        const data: Post[] = await res.json();

        setPosts(data);
        setPage(1);
        setHasMore(data.length === POSTS_PER_PAGE);
      } catch (err: any) {
        if (err.name !== 'AbortError') {
          setError(err.message || 'Lỗi tải dữ liệu');
        }
      } finally {
        setLoading(false);
      }
    }

    loadInitialPosts();
    return () => controller.abort();
  }, [refreshKey]);

  /**
   * Tải thêm bài viết (infinite scroll)
   */
  async function loadMore() {
    if (loadingMore || !hasMore) return;

    try {
      setLoadingMore(true);

      const res = await fetch(
        `${POSTS_API}?_page=${page + 1}&_limit=${POSTS_PER_PAGE}`,
      );

      if (!res.ok) throw new Error('Network error');
      await new Promise((res) => setTimeout(res, 1000));
      const data: Post[] = await res.json();

      if (data.length === 0) {
        setHasMore(false);
      } else {
        setPosts((prev) => [...prev, ...data]);
        setPage((prev) => prev + 1);
      }
    } catch (err) {
      console.error('Load more error', err);
    } finally {
      setLoadingMore(false);
    }
  }

  /**
   * Polling giả lập để phát hiện bài viết mới
   */
  useEffect(() => {
    if (!polling) return;

    const interval = setInterval(() => {
      if (Math.random() > 0.7) {
        setNewPostsCount((p) => p + 1);
      }
    }, 5000);

    return () => clearInterval(interval);
  }, [polling]);

  // ==================== ACTIONS ====================

  /**
   * Làm mới toàn bộ nguồn cấp (reset state và tải lại page 1)
   */
  const handleRefresh = () => {
    setPosts([]);
    setPage(1);
    setHasMore(true);
    setNewPostsCount(0);
    setRefreshKey((k) => k + 1);
  };

  /**
   * Xử lý khi nhấn vào banner "có bài viết mới"
   */
  const handleLoadNew = () => {
    handleRefresh();
  };

  // ==================== RENDER ====================

  if (loading) {
    return (
      <div
        style={{
          height: '100vh',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          alignItems: 'center',
          background: '#f8f9fa',
        }}
      >
        <div
          style={{
            fontSize: 60,
            animation: 'spin 1.5s linear infinite',
            marginBottom: 16,
          }}
        >

        </div>
        <p style={{ fontSize: 18, color: '#555' }}>Đang tải nguồn cấp...</p>
      </div>
    );
  }

  if (error) {
    return (
      <div
        style={{
          height: '100vh',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          alignItems: 'center',
          background: '#fff',
          padding: 20,
        }}
      >
        <p style={{ fontSize: 20, color: '#d32f2f', marginBottom: 16 }}>
          Lỗi: {error}
        </p>
        <button
          onClick={handleRefresh}
          style={{
            padding: '12px 32px',
            fontSize: 16,
            background: '#1976d2',
            color: 'white',
            border: 'none',
            borderRadius: 8,
            cursor: 'pointer',
            boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
          }}
        >
          Thử lại
        </button>
      </div>
    );
  }

  return (
    <>
      <style>{`
        @keyframes spin {
          from { transform: rotate(0deg); }
          to   { transform: rotate(360deg); }
        }
      `}</style>

      <div
        onTouchStart={onTouchStart}
        onTouchMove={onTouchMove}
        onTouchEnd={onTouchEnd}
        style={{
          minHeight: '100vh',
          background: '#f0f2f5',
          paddingBottom: 60,
        }}
      >
        {/* Pull indicator */}
        <div
          style={{
            position: 'sticky',
            top: 0,
            height: pullDistance,
            overflow: 'hidden',
            transition: pulling
              ? 'none'
              : 'height 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'flex-end',
            zIndex: 1000,
            background: 'linear-gradient(to bottom, #e3f2fd, transparent)',
          }}
        >
          {pullDistance > 0 && (
            <div
              style={{
                paddingBottom: 16,
                fontSize: 15,
                fontWeight: 500,
                color: pullDistance >= PULL_THRESHOLD ? '#1976d2' : '#757575',
                opacity: Math.min(pullDistance / PULL_THRESHOLD, 1),
                display: 'flex',
                alignItems: 'center',
                gap: 8,
              }}
            >
              {pullDistance < PULL_THRESHOLD ? (
                <>⬇️ Kéo xuống để làm mới</>
              ) : (
                <>🔄 Thả tay để làm mới</>
              )}
            </div>
          )}
        </div>

        {/* Header */}
        <div
          style={{
            position: 'sticky',
            top: 0,
            zIndex: 999,
            background: 'white',
            padding: '16px 20px',
            borderBottom: '1px solid #e0e0e0',
            boxShadow: '0 2px 10px rgba(0,0,0,0.05)',
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
          }}
        >
          <h2 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>
            📱 Nguồn cấp
          </h2>
          <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
            <label
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: 6,
                fontSize: 14,
              }}
            >
              <input
                type='checkbox'
                checked={polling}
                onChange={(e) => setPolling(e.target.checked)}
                style={{ accentColor: '#1976d2' }}
              />
              Polling
            </label>
            <button
              onClick={handleRefresh}
              style={{
                padding: '8px 16px',
                background: '#1976d2',
                color: 'white',
                border: 'none',
                borderRadius: 8,
                fontWeight: 500,
                cursor: 'pointer',
                boxShadow: '0 2px 6px rgba(25,118,210,0.3)',
              }}
            >
              Làm mới
            </button>
          </div>
        </div>

        {/* New posts banner */}
        {newPostsCount > 0 && (
          <div
            onClick={handleLoadNew}
            style={{
              margin: '16px 20px',
              padding: '14px',
              background: 'linear-gradient(135deg, #4caf50, #388e3c)',
              color: 'white',
              borderRadius: 12,
              textAlign: 'center',
              fontWeight: 600,
              cursor: 'pointer',
              boxShadow: '0 4px 12px rgba(76,175,80,0.3)',
            }}
          >
            Có {newPostsCount} bài viết mới – Nhấn để xem ngay
          </div>
        )}

        {/* Posts với số thứ tự */}
        {posts.map((post, index) => (
          <div
            key={post.id}
            style={{
              margin: '16px 20px',
              padding: '16px',
              background: 'white',
              borderRadius: 12,
              boxShadow: '0 2px 12px rgba(0,0,0,0.08)',
              position: 'relative',
            }}
          >
            {/* Số thứ tự */}
            <div
              style={{
                position: 'absolute',
                top: -12,
                left: 16,
                background: '#1976d2',
                color: 'white',
                fontSize: 14,
                fontWeight: 700,
                padding: '4px 10px',
                borderRadius: 16,
                boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
              }}
            >
              #{index + 1}
            </div>

            <div
              style={{
                display: 'flex',
                alignItems: 'center',
                marginBottom: 12,
                paddingTop: 12,
              }}
            >
              <div
                style={{
                  width: 40,
                  height: 40,
                  borderRadius: '50%',
                  background: '#e0e0e0',
                  marginRight: 12,
                }}
              />
              <div>
                <div style={{ fontWeight: 600 }}>Người dùng {post.userId}</div>
                <div style={{ fontSize: 13, color: '#757575' }}>
                  2 giờ trước
                </div>
              </div>
            </div>
            <h3 style={{ margin: '0 0 12px', fontSize: 18 }}>{post.title}</h3>
            <p style={{ margin: 0, lineHeight: 1.6, color: '#424242' }}>
              {post.body}
            </p>
            <div
              style={{
                marginTop: 16,
                display: 'flex',
                gap: 24,
                color: '#757575',
                fontSize: 14,
              }}
            >
              <span>👍 {Math.floor(Math.random() * 200 + 10)}</span>
              <span>💬 {Math.floor(Math.random() * 50 + 5)}</span>
              <span>🔁 {Math.floor(Math.random() * 30)}</span>
            </div>
          </div>
        ))}

        {/* Load more trigger */}
        {hasMore && (
          <div
            ref={setObserverTarget}
            style={{
              padding: '40px 20px',
              textAlign: 'center',
              color: '#757575',
              fontSize: 15,
            }}
          >
            {loadingMore ? (
              <div
                style={{
                  display: 'flex',
                  justifyContent: 'center',
                  alignItems: 'center',
                  gap: 8,
                }}
              >
                <div
                  style={{
                    width: 20,
                    height: 20,
                    border: '3px solid #1976d2',
                    borderTopColor: 'transparent',
                    borderRadius: '50%',
                    animation: 'spin 1s linear infinite',
                  }}
                />
                Đang tải thêm...
              </div>
            ) : (
              'Cuộn xuống để tải thêm'
            )}
          </div>
        )}

        {!hasMore && posts.length > 0 && (
          <div
            style={{
              padding: '60px 20px',
              textAlign: 'center',
              color: '#9e9e9e',
              fontSize: 16,
            }}
          >
            🎉 Bạn đã xem hết tất cả bài viết
          </div>
        )}
      </div>
    </>
  );
}

export default SocialFeed;

⭐⭐⭐⭐⭐ Level 5: Production Challenge (90 phút)

jsx
/**
 * 🎯 Mục tiêu: Xây dựng Multi-Tab Analytics Dashboard với các best practices
 * ⏱️ Thời gian gợi ý: 90–120 phút
 *
 * 📋 Yêu cầu chức năng
 * Dashboard gồm 4 tab:
 * 1. Overview     - Tổng quan số liệu (parallel fetch)
 * 2. Users        - Danh sách người dùng (có phân trang + tìm kiếm)
 * 3. Posts        - Bài viết theo người dùng (dependent fetch)
 * 4. Activity     - Hoạt động gần đây (polling giả lập real-time)
 *
 * Yêu cầu kỹ thuật bắt buộc:
 * - Lazy loading: chỉ fetch dữ liệu khi tab được chọn lần đầu
 * - Cache dữ liệu: không fetch lại nếu đã có dữ liệu (trừ khi refresh)
 * - Hủy request khi chuyển tab (sử dụng AbortController)
 * - Nút Refresh riêng cho từng tab (buộc fetch lại)
 * - Xử lý loading / error riêng cho từng tab
 * - Cleanup toàn bộ request/polling khi component unmount
 *
 * 🏗️ Gợi ý kiến trúc
 * - Component cha (MultiTabDashboard) quản lý activeTab + cache chung
 * - Mỗi tab là component riêng, tự quản lý fetching + loading + error
 * - Sử dụng AbortController trong useEffect cleanup
 * - Cache được lưu ở parent → truyền xuống tab qua props
 *
 * API sử dụng: https://jsonplaceholder.typicode.com
 *
 * ✅ Production-grade Checklist
 * - [ ] Lazy loading (chỉ fetch khi tab active + chưa có cache)
 * - [ ] Cache dữ liệu tab (không fetch lại khi quay lại tab cũ)
 * - [ ] Hủy request khi chuyển tab / unmount
 * - [ ] Nút Refresh cho từng tab
 * - [ ] Loading indicator riêng mỗi tab
 * - [ ] Xử lý lỗi + retry cơ bản mỗi tab
 * - [ ] Cleanup đúng (abort fetch, clearInterval nếu có)
 */

// Starter code

import { useState } from 'react';

const API_BASE = 'https://jsonplaceholder.typicode.com';

const TABS = {
  overview:  { id: 'overview',  label: 'Overview',  icon: '📊' },
  users:     { id: 'users',     label: 'Users',     icon: '👥' },
  posts:     { id: 'posts',     label: 'Posts',     icon: '📝' },
  activity:  { id: 'activity',  label: 'Activity',  icon: '⚡' },
} as const;

type TabId = keyof typeof TABS;

function MultiTabDashboard() {
  const [activeTab, setActiveTab] = useState<TabId>('overview');
  const [cache, setCache] = useState<Record<TabId, any>>({
    overview: null,
    users: null,
    posts: null,
    activity: null,
  });

  const handleRefresh = (tabId: TabId) => {
    setCache(prev => ({ ...prev, [tabId]: null }));
  };

  return (
    <div style={{ maxWidth: '1400px', margin: '0 auto', padding: '24px' }}>
      <h1>Multi-Tab Analytics Dashboard</h1>

      {/* Tab Navigation */}
      <div
        style={{
          display: 'flex',
          gap: '8px',
          marginBottom: '24px',
          borderBottom: '2px solid #e0e0e0',
        }}
      >
        {Object.values(TABS).map(tab => (
          <button
            key={tab.id}
            onClick={() => setActiveTab(tab.id)}
            style={{
              padding: '12px 24px',
              fontSize: '16px',
              background: activeTab === tab.id ? '#0069d9' : '#f8f9fa',
              color: activeTab === tab.id ? 'white' : '#333',
              border: '1px solid',
              borderColor: activeTab === tab.id ? '#0069d9' : '#ced4da',
              borderRadius: '6px 6px 0 0',
              cursor: 'pointer',
              fontWeight: activeTab === tab.id ? 600 : 400,
            }}
          >
            {tab.icon} {tab.label}
          </button>
        ))}
      </div>

      {/* Tab Content */}
      <div>
        {activeTab === 'overview' && (
          <OverviewTab
            cachedData={cache.overview}
            onCacheUpdate={(data) => setCache(p => ({ ...p, overview: data }))}
            onRefresh={() => handleRefresh('overview')}
          />
        )}

        {activeTab === 'users' && (
          <UsersTab
            cachedData={cache.users}
            onCacheUpdate={(data) => setCache(p => ({ ...p, users: data }))}
            onRefresh={() => handleRefresh('users')}
          />
        )}

        {activeTab === 'posts' && (
          <PostsTab
            cachedData={cache.posts}
            onCacheUpdate={(data) => setCache(p => ({ ...p, posts: data }))}
            onRefresh={() => handleRefresh('posts')}
          />
        )}

        {activeTab === 'activity' && (
          <ActivityTab
            cachedData={cache.activity}
            onCacheUpdate={(data) => setCache(p => ({ ...p, activity: data }))}
            onRefresh={() => handleRefresh('activity')}
          />
        )}
      </div>

      {/* Hướng dẫn triển khai */}
      <div style={{
        marginTop: '48px',
        padding: '20px',
        background: '#f8f9fa',
        borderRadius: '8px',
        fontSize: '15px',
      }}>
        <h3>Triển khai các tab (gợi ý chi tiết)</h3>

        <p><strong>OverviewTab</strong></p>
        <ul>
          <li>Parallel fetch: /users, /posts, /comments → chỉ lấy length</li>
          <li>Sử dụng Promise.allSettled</li>
          <li>Hiển thị dạng stat cards</li>
        </ul>

        <p><strong>UsersTab</strong></p>
        <ul>
          <li>Fetch /users, hỗ trợ phân trang (giả lập 10 item/page)</li>
          <li>Tìm kiếm theo name (debounce ~300–500ms)</li>
          <li>Bảng đơn giản (table hoặc div grid)</li>
        </ul>

        <p><strong>PostsTab</strong></p>
        <ul>
          <li>Dropdown chọn user (từ /users)</li>
          <li>Fetch /posts?userId=xxx khi chọn user</li>
          <li>Hỗ trợ abort cả 2 request (users list + posts)</li>
        </ul>

        <p><strong>ActivityTab</strong></p>
        <ul>
          <li>Giả lập polling: fetch /posts hoặc /comments mới nhất mỗi 8–10 giây</li>
          <li>Hiển thị dạng feed (mới nhất ở trên)</li>
          <li>Có nút toggle polling (pause/resume)</li>
        </ul>

        <h4 style={{ marginTop: '24px' }}>Yêu cầu kỹ thuật cần đạt</h4>
        <ul style={{ color: '#2c3e50' }}>
          <li>Lazy loading tự nhiên (fetch trong useEffect khi mount)</li>
          <li>Cache → hiển thị dữ liệu cũ ngay lập tức khi quay lại tab</li>
          <li>AbortController hủy fetch khi chuyển tab / unmount</li>
          <li>Nút Refresh → xóa cache → buộc fetch lại</li>
          <li>Loading & error state rõ ràng từng tab</li>
          <li>Cleanup interval (nếu có polling) khi unmount</li>
        </ul>
      </div>
    </div>
  );
}

// Placeholder cho các tab component
function OverviewTab({ cachedData, onCacheUpdate, onRefresh }: any) {
  return <div>[Implement OverviewTab]</div>;
}

function UsersTab({ cachedData, onCacheUpdate, onRefresh }: any) {
  return <div>[Implement UsersTab]</div>;
}

function PostsTab({ cachedData, onCacheUpdate, onRefresh }: any) {
  return <div>[Implement PostsTab]</div>;
}

function ActivityTab({ cachedData, onCacheUpdate, onRefresh }: any) {
  return <div>[Implement ActivityTab]</div>;
}

export default MultiTabDashboard;
💡 Solution
tsx
import { useEffect, useRef, useState } from 'react';

const API_BASE = 'https://jsonplaceholder.typicode.com';

// Tab configuration
const TABS = {
  overview: { id: 'overview', label: 'Overview', icon: '📊' },
  users: { id: 'users', label: 'Users', icon: '👥' },
  posts: { id: 'posts', label: 'Posts', icon: '📝' },
  activity: { id: 'activity', label: 'Activity', icon: '⚡' },
} as const;

type TabId = keyof typeof TABS;

/**
 * Interface cho dữ liệu cache của từng tab
 */
interface TabCache {
  overview: OverviewData | null;
  users: User[] | null;
  posts: Post[] | null;
  activity: ActivityItem[] | null;
}

interface OverviewData {
  usersCount: number;
  postsCount: number;
  commentsCount: number;
  lastUpdated: string;
}

interface User {
  id: number;
  name: string;
  username: string;
  email: string;
}

interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

interface ActivityItem {
  id: number;
  type: 'post' | 'comment' | 'like';
  userId: number;
  timestamp: string;
  message: string;
}

/**
 * Dashboard đa tab với lazy loading, caching, cancellation và refresh riêng từng tab
 */
function MultiTabDashboard() {
  const [activeTab, setActiveTab] = useState<TabId>('overview');
  const [cache, setCache] = useState<TabCache>({
    overview: null,
    users: null,
    posts: null,
    activity: null,
  });

  // Abort controllers cho từng tab
  const abortControllers = useRef<Map<TabId, AbortController>>(new Map());

  const handleTabChange = (tabId: TabId) => {
    // Cancel request tab cũ nếu đang fetch
    const prevController = abortControllers.current.get(activeTab);
    if (prevController) {
      prevController.abort();
      abortControllers.current.delete(activeTab);
    }
    setActiveTab(tabId);
  };

  const handleRefreshTab = (tabId: TabId) => {
    setCache((prev) => ({ ...prev, [tabId]: null }));

    const controller = abortControllers.current.get(tabId);
    if (controller) {
      controller.abort();
      abortControllers.current.delete(tabId);
    }
  };

  // Cleanup toàn bộ khi component unmount
  useEffect(() => {
    return () => {
      abortControllers.current.forEach((ctrl) => ctrl.abort());
      abortControllers.current.clear();
    };
  }, []);

  return (
    <div style={{ maxWidth: '1400px', margin: '0 auto', padding: '20px' }}>
      <h1>🎯 Multi-Tab Analytics Dashboard</h1>

      {/* Tab Navigation */}
      <div
        style={{
          display: 'flex',
          gap: '10px',
          marginBottom: '20px',
          borderBottom: '2px solid #ddd',
          paddingBottom: '10px',
        }}
      >
        {Object.values(TABS).map((tab) => (
          <button
            key={tab.id}
            onClick={() => handleTabChange(tab.id as TabId)}
            style={{
              padding: '10px 20px',
              background: activeTab === tab.id ? '#4CAF50' : 'white',
              color: activeTab === tab.id ? 'white' : '#333',
              border: '2px solid',
              borderColor: activeTab === tab.id ? '#4CAF50' : '#ddd',
              borderRadius: '8px 8px 0 0',
              cursor: 'pointer',
              fontSize: '16px',
              fontWeight: activeTab === tab.id ? 'bold' : 'normal',
            }}
          >
            {tab.icon} {tab.label}
          </button>
        ))}
      </div>

      {/* Tab Content */}
      <div>
        {activeTab === 'overview' && (
          <OverviewTab
            cache={cache.overview}
            onDataLoad={(data) =>
              setCache((prev) => ({ ...prev, overview: data }))
            }
            onRefresh={() => handleRefreshTab('overview')}
          />
        )}
        {activeTab === 'users' && (
          <UsersTab
            cache={cache.users}
            onDataLoad={(data) =>
              setCache((prev) => ({ ...prev, users: data }))
            }
            onRefresh={() => handleRefreshTab('users')}
          />
        )}
        {activeTab === 'posts' && (
          <PostsTab
            cache={cache.posts}
            onDataLoad={(data) =>
              setCache((prev) => ({ ...prev, posts: data }))
            }
            onRefresh={() => handleRefreshTab('posts')}
          />
        )}
        {activeTab === 'activity' && (
          <ActivityTab
            cache={cache.activity}
            onDataLoad={(data) =>
              setCache((prev) => ({ ...prev, activity: data }))
            }
            onRefresh={() => handleRefreshTab('activity')}
          />
        )}
      </div>
    </div>
  );
}

/**
 * Tab Overview: Parallel fetch nhiều thống kê
 */
function OverviewTab({
  cache,
  onDataLoad,
  onRefresh,
}: {
  cache: OverviewData | null;
  onDataLoad: (data: OverviewData) => void;
  onRefresh: () => void;
}) {
  const [loading, setLoading] = useState(!cache);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (cache) return;

    const controller = new AbortController();
    const { signal } = controller;

    async function fetchOverview() {
      setLoading(true);
      setError(null);

      try {
        const [usersRes, postsRes, commentsRes] = await Promise.allSettled([
          fetch(`${API_BASE}/users`, { signal }).then((r) => r.json()),
          fetch(`${API_BASE}/posts`, { signal }).then((r) => r.json()),
          fetch(`${API_BASE}/comments`, { signal }).then((r) => r.json()),
        ]);

        const users =
          usersRes.status === 'fulfilled' ? usersRes.value.length : 0;
        const posts =
          postsRes.status === 'fulfilled' ? postsRes.value.length : 0;
        const comments =
          commentsRes.status === 'fulfilled' ? commentsRes.value.length : 0;

        const data: OverviewData = {
          usersCount: users,
          postsCount: posts,
          commentsCount: comments,
          lastUpdated: new Date().toLocaleTimeString(),
        };

        onDataLoad(data);
      } catch (err: any) {
        if (err.name !== 'AbortError') {
          setError('Không thể tải dữ liệu tổng quan');
        }
      } finally {
        setLoading(false);
      }
    }

    fetchOverview();

    return () => controller.abort();
  }, [cache]);

  if (loading)
    return (
      <div style={{ padding: '40px', textAlign: 'center' }}>
        Đang tải tổng quan...
      </div>
    );
  if (error)
    return (
      <div style={{ color: 'red', padding: '20px' }}>
        {error} <button onClick={onRefresh}>Thử lại</button>
      </div>
    );

  if (!cache) return null;

  return (
    <div>
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          marginBottom: 20,
        }}
      >
        <h2>📊 Tổng quan</h2>
        <button
          onClick={onRefresh}
          style={{
            padding: '8px 16px',
            background: '#2196F3',
            color: 'white',
            border: 'none',
            borderRadius: 6,
          }}
        >
          Làm mới
        </button>
      </div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
          gap: 20,
        }}
      >
        <StatCard
          title='Người dùng'
          value={cache.usersCount}
        />
        <StatCard
          title='Bài viết'
          value={cache.postsCount}
        />
        <StatCard
          title='Bình luận'
          value={cache.commentsCount}
        />
        <StatCard
          title='Cập nhật lần cuối'
          value={cache.lastUpdated}
        />
      </div>
    </div>
  );
}

function StatCard({ title, value }: { title: string; value: number | string }) {
  return (
    <div
      style={{
        padding: 20,
        background: 'white',
        borderRadius: 12,
        boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        textAlign: 'center',
      }}
    >
      <h4 style={{ margin: '0 0 8px', color: '#555' }}>{title}</h4>
      <p
        style={{
          fontSize: 32,
          fontWeight: 'bold',
          margin: 0,
          color: '#1976d2',
        }}
      >
        {value}
      </p>
    </div>
  );
}

/**
 * Tab Users: Bảng người dùng với phân trang & tìm kiếm debounce
 */
function UsersTab({
  cache,
  onDataLoad,
  onRefresh,
}: {
  cache: User[] | null;
  onDataLoad: (data: User[]) => void;
  onRefresh: () => void;
}) {
  const [users, setUsers] = useState<User[]>(cache || []);
  const [loading, setLoading] = useState(!cache);
  const [error, setError] = useState<string | null>(null);
  const [search, setSearch] = useState('');
  const [debouncedSearch, setDebouncedSearch] = useState('');

  // Debounce search (không dùng useCallback)
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedSearch(search);
    }, 500);

    return () => clearTimeout(timer);
  }, [search]);

  useEffect(() => {
    if (cache) return;
    const controller = new AbortController();

    async function fetchUsers() {
      setLoading(true);
      setError(null);

      try {
        const res = await fetch(`${API_BASE}/users`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error('Không tải được người dùng');
        const data: User[] = await res.json();
        setUsers(data);
        onDataLoad(data);
      } catch (err: any) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUsers();

    return () => controller.abort();
  }, [cache]);

  const filteredUsers = users.filter(
    (u) =>
      u.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
      u.username.toLowerCase().includes(debouncedSearch.toLowerCase()),
  );

  if (loading)
    return (
      <div style={{ padding: '40px', textAlign: 'center' }}>
        Đang tải danh sách người dùng...
      </div>
    );
  if (error)
    return (
      <div style={{ color: 'red', padding: '20px' }}>
        {error} <button onClick={onRefresh}>Thử lại</button>
      </div>
    );

  return (
    <div>
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          marginBottom: 20,
        }}
      >
        <h2>👥 Người dùng</h2>
        <div style={{ display: 'flex', gap: 12 }}>
          <input
            type='text'
            placeholder='Tìm theo tên hoặc username...'
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            style={{
              padding: '8px 12px',
              borderRadius: 6,
              border: '1px solid #ddd',
              width: 280,
            }}
          />
          <button
            onClick={onRefresh}
            style={{
              padding: '8px 16px',
              background: '#2196F3',
              color: 'white',
              border: 'none',
              borderRadius: 6,
            }}
          >
            Làm mới
          </button>
        </div>
      </div>

      <table
        style={{
          width: '100%',
          borderCollapse: 'collapse',
          background: 'white',
          borderRadius: 8,
          overflow: 'hidden',
        }}
      >
        <thead>
          <tr style={{ background: '#f5f5f5' }}>
            <th style={{ padding: 12, textAlign: 'left' }}>ID</th>
            <th style={{ padding: 12, textAlign: 'left' }}>Tên</th>
            <th style={{ padding: 12, textAlign: 'left' }}>Username</th>
            <th style={{ padding: 12, textAlign: 'left' }}>Email</th>
          </tr>
        </thead>
        <tbody>
          {filteredUsers.map((user) => (
            <tr
              key={user.id}
              style={{ borderBottom: '1px solid #eee' }}
            >
              <td style={{ padding: 12 }}>{user.id}</td>
              <td style={{ padding: 12 }}>{user.name}</td>
              <td style={{ padding: 12 }}>{user.username}</td>
              <td style={{ padding: 12 }}>{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>

      {filteredUsers.length === 0 && (
        <p style={{ textAlign: 'center', padding: 40, color: '#777' }}>
          Không tìm thấy người dùng nào
        </p>
      )}
    </div>
  );
}

/**
 * Tab Posts: Chọn user → tải bài viết tương ứng
 */
function PostsTab({
  cache,
  onDataLoad,
  onRefresh,
}: {
  cache: Post[] | null;
  onDataLoad: (data: Post[]) => void;
  onRefresh: () => void;
}) {
  const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
  const [posts, setPosts] = useState<Post[]>(cache || []);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [users, setUsers] = useState<User[]>([]);

  // Tải danh sách user cho dropdown
  useEffect(() => {
    async function fetchUsers() {
      try {
        const res = await fetch(`${API_BASE}/users`);
        if (!res.ok) throw new Error('Không tải được danh sách người dùng');
        const data: User[] = await res.json();
        setUsers(data);
      } catch {}
    }
    fetchUsers();
  }, []);

  // Tải bài viết khi chọn user
  useEffect(() => {
    if (!selectedUserId) return;

    const controller = new AbortController();

    async function fetchPosts() {
      setLoading(true);
      setError(null);

      try {
        const res = await fetch(`${API_BASE}/posts?userId=${selectedUserId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error('Không tải được bài viết');
        const data: Post[] = await res.json();
        setPosts(data);
        onDataLoad(data);
      } catch (err: any) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchPosts();

    return () => controller.abort();
  }, [selectedUserId]);

  const handleUserChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    setSelectedUserId(Number(e.target.value) || null);
  };

  return (
    <div>
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          marginBottom: 20,
        }}
      >
        <h2>📝 Bài viết</h2>
        <div style={{ display: 'flex', gap: 12 }}>
          <select
            value={selectedUserId || ''}
            onChange={handleUserChange}
            style={{
              padding: '8px 12px',
              borderRadius: 6,
              border: '1px solid #ddd',
            }}
          >
            <option value=''>Chọn người dùng...</option>
            {users.map((u) => (
              <option
                key={u.id}
                value={u.id}
              >
                {u.name} (@{u.username})
              </option>
            ))}
          </select>
          <button
            onClick={onRefresh}
            style={{
              padding: '8px 16px',
              background: '#2196F3',
              color: 'white',
              border: 'none',
              borderRadius: 6,
            }}
          >
            Làm mới
          </button>
        </div>
      </div>

      {loading && (
        <div style={{ padding: '40px', textAlign: 'center' }}>
          Đang tải bài viết...
        </div>
      )}
      {error && (
        <div style={{ color: 'red', padding: '20px' }}>
          {error} <button onClick={onRefresh}>Thử lại</button>
        </div>
      )}

      {!loading && !error && selectedUserId && (
        <>
          {posts.length === 0 ? (
            <p style={{ textAlign: 'center', padding: 40, color: '#777' }}>
              Người dùng này chưa có bài viết nào
            </p>
          ) : (
            posts.map((post) => (
              <div
                key={post.id}
                style={{
                  marginBottom: 20,
                  padding: 20,
                  background: 'white',
                  borderRadius: 12,
                  boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
                }}
              >
                <h3 style={{ margin: '0 0 12px' }}>{post.title}</h3>
                <p style={{ margin: 0, color: '#555' }}>{post.body}</p>
              </div>
            ))
          )}
        </>
      )}

      {!selectedUserId && !loading && (
        <p style={{ textAlign: 'center', padding: 40, color: '#777' }}>
          Vui lòng chọn một người dùng để xem bài viết
        </p>
      )}
    </div>
  );
}

/**
 * Tab Activity: Polling real-time + toggle bật/tắt
 */
function ActivityTab({
  cache,
  onDataLoad,
  onRefresh,
}: {
  cache: ActivityItem[] | null;
  onDataLoad: (data: ActivityItem[]) => void;
  onRefresh: () => void;
}) {
  const [activities, setActivities] = useState<ActivityItem[]>(cache || []);
  const [polling, setPolling] = useState<boolean>(true);

  // Hàm giả lập fetch activity mới
  const fetchActivity = () => {
    const newItem: ActivityItem = {
      id: Date.now(),
      type: Math.random() > 0.5 ? 'post' : 'comment',
      userId: Math.floor(Math.random() * 10) + 1,
      timestamp: new Date().toLocaleTimeString(),
      message: `Hoạt động mới từ người dùng ${Math.floor(Math.random() * 10) + 1}`,
    };

    setActivities((prev) => [newItem, ...prev].slice(0, 20));
    onDataLoad([newItem, ...activities.slice(0, 19)]);
  };

  useEffect(() => {
    if (cache) return;
    // Load dữ liệu ban đầu
    fetchActivity();
  }, [cache]);

  // Polling
  useEffect(() => {
    if (!polling) return;

    const interval = setInterval(fetchActivity, 10000); // 10 giây

    return () => clearInterval(interval);
  }, [polling]);

  return (
    <div>
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          marginBottom: 20,
        }}
      >
        <h2>⚡ Hoạt động thời gian thực</h2>
        <div style={{ display: 'flex', gap: 12 }}>
          <label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <input
              type='checkbox'
              checked={polling}
              onChange={(e) => setPolling(e.target.checked)}
            />
            Polling (10s)
          </label>
          <button
            onClick={onRefresh}
            style={{
              padding: '8px 16px',
              background: '#2196F3',
              color: 'white',
              border: 'none',
              borderRadius: 6,
            }}
          >
            Làm mới
          </button>
        </div>
      </div>

      {activities.length === 0 ? (
        <p style={{ textAlign: 'center', padding: 40, color: '#777' }}>
          Chưa có hoạt động nào
        </p>
      ) : (
        <div style={{ maxHeight: 600, overflowY: 'auto' }}>
          {activities.map((item) => (
            <div
              key={item.id}
              style={{
                padding: 16,
                marginBottom: 12,
                background: 'white',
                borderRadius: 8,
                borderLeft: `4px solid ${item.type === 'post' ? '#4CAF50' : '#FF9800'}`,
              }}
            >
              <div style={{ fontWeight: 600, marginBottom: 4 }}>
                {item.type === 'post' ? '📝 Đăng bài mới' : '💬 Bình luận'}
              </div>
              <div style={{ fontSize: 14, color: '#555' }}>{item.message}</div>
              <div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
                Người dùng #{item.userId} • {item.timestamp}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

export default MultiTabDashboard;

Kết quả ví dụ khi chạy:

  • Mở trang → tab Overview tự động load ngay, hiển thị 4 stat card (users, posts, comments, thời gian cập nhật)
  • Click tab Users → fetch 1 lần, hiển thị bảng 10 người dùng + ô tìm kiếm hoạt động mượt (debounce 500ms)
  • Click tab Posts → dropdown hiện danh sách user, chọn user → fetch và hiển thị bài viết của user đó (không loop)
  • Click tab Activity → tự động thêm hoạt động mới mỗi 10s, có toggle bật/tắt polling
  • Refresh bất kỳ tab → nút "Làm mới" xóa cache và tải lại dữ liệu tab đó
  • Switch tab nhanh → request cũ bị hủy, không lỗi console
  • Unmount component → tất cả request còn lại bị abort

📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)

Bảng So Sánh: Cancellation Strategies

StrategyCodeProsConsUse Case
Ignore Outdatedlet isLatest = true;
return () => { isLatest = false; }
✅ Simple
✅ Works everywhere
❌ Requests still complete
❌ Wastes bandwidth
Non-fetch async
AbortControllerconst ctrl = new AbortController();
fetch(url, { signal: ctrl.signal })
✅ Actually cancels
✅ Saves bandwidth
❌ Only for fetch
❌ Slightly complex
fetch requests
Promise RacingPromise.race([fetch(), timeout()])✅ Timeout control❌ Doesn't cancel
❌ Complex
Timeout scenarios

Bảng So Sánh: Sequential vs Parallel

AspectSequentialParallel
Patternawait A; await B; await C;Promise.all([A, B, C])
TimingA → B → C (cumulative)A, B, C (simultaneous)
Speed~1500ms (500+500+500)~500ms (max of all)
DependenciesB depends on A resultAll independent
Error Handlingtry/catch per requestPromise.allSettled
Use WhenB needs data from AAll independent data

Decision Tree: Request Strategy

Cần fetch data?

├─ Request B depends on data from Request A?
│  → SEQUENTIAL (await A, then B)
│  → useEffect deps: [dataA]
│  → Example: Fetch user → Fetch user's posts

├─ Multiple INDEPENDENT requests?
│  │
│  ├─ All must succeed?
│  │  → Promise.all() - fails if any fails
│  │
│  └─ Independent failures OK?
│     → Promise.allSettled() - continues despite failures
│     → Example: Dashboard stats (show what loaded)

├─ User might change input quickly?
│  │
│  ├─ Each keystroke triggers fetch?
│  │  → Debounce + AbortController
│  │  → Example: Search autocomplete
│  │
│  └─ Each selection triggers fetch?
│     → AbortController only
│     → Example: Dropdown filter

└─ Long-running request?
   → AbortController + timeout
   → Example: Large file upload

🧪 PHẦN 5: DEBUG LAB (20 phút)

Bug #1: Race Condition - Wrong Data Displayed 🏎️

jsx
/**
 * 🐛 BUG: User sees wrong user's data
 * 🎯 Nhiệm vụ: Identify and fix race condition
 */

function BuggyUserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // ❌ NO CANCELLATION!
    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data); // ← Nào request về trước?
    }

    fetchUser();
  }, [userId]);

  return <div>{user?.name}</div>;
}

// 🤔 SCENARIO:
// t=0ms:   userId=1 → Request R1 fires (takes 500ms)
// t=100ms: userId=2 → Request R2 fires (takes 200ms)
// t=300ms: R2 returns → setUser(user2) → Display "User 2" ✅
// t=500ms: R1 returns → setUser(user1) → Display "User 1" ❌ WRONG!

// USER SEES: User 1 (but userId prop = 2!)

// ✅ FIX: AbortController
function Fixed({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchUser() {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        const data = await response.json();
        setUser(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error(err);
        }
      }
    }

    fetchUser();

    return () => controller.abort();
  }, [userId]);

  // FIXED TIMELINE:
  // t=0ms:   userId=1 → R1 fires
  // t=100ms: userId=2 → R1 ABORTED, R2 fires
  // t=300ms: R2 returns → Display "User 2" ✅ CORRECT!
  // t=500ms: R1 aborted (no update)

  return <div>{user?.name}</div>;
}

// 🎓 BÀI HỌC:
// - Race conditions = wrong data displayed
// - AbortController prevents stale updates
// - Always cancel old requests

Bug #2: Promise.all() Single Failure 💥

jsx
/**
 * 🐛 BUG: One failed request breaks entire dashboard
 * 🎯 Nhiệm vụ: Fix with Promise.allSettled
 */

function BuggyDashboard() {
  const [stats, setStats] = useState(null);

  useEffect(() => {
    async function fetchStats() {
      // ❌ Promise.all fails if ANY request fails
      const [users, posts, comments] = await Promise.all([
        fetch('/api/users').then((r) => r.json()),
        fetch('/api/posts').then((r) => r.json()),
        fetch('/api/comments').then((r) => r.json()), // ← THIS FAILS!
      ]);

      setStats({ users, posts, comments });
    }

    fetchStats().catch((err) => {
      console.error('Failed to load dashboard:', err);
      // User sees NOTHING, even though users & posts succeeded
    });
  }, []);

  // ...
}

// 🤔 PROBLEM:
// - /api/comments returns 500
// - Promise.all() rejects entirely
// - setStats never called
// - User sees error, no data at all
// - ❌ Users and Posts data is lost!

// ✅ FIX: Promise.allSettled
function Fixed() {
  const [stats, setStats] = useState({
    users: null,
    posts: null,
    comments: null,
  });

  useEffect(() => {
    async function fetchStats() {
      const results = await Promise.allSettled([
        fetch('/api/users').then((r) => r.json()),
        fetch('/api/posts').then((r) => r.json()),
        fetch('/api/comments').then((r) => r.json()),
      ]);

      const [usersResult, postsResult, commentsResult] = results;

      setStats({
        users: usersResult.status === 'fulfilled' ? usersResult.value : null,
        posts: postsResult.status === 'fulfilled' ? postsResult.value : null,
        comments:
          commentsResult.status === 'fulfilled' ? commentsResult.value : null,
      });
    }

    fetchStats();
  }, []);

  // RESULT:
  // - Users: ✅ Loaded
  // - Posts: ✅ Loaded
  // - Comments: ❌ Failed (but others still show!)
  // → Partial success! Better UX!

  // ...
}

// 🎓 BÀI HỌC:
// - Promise.all() = all-or-nothing
// - Promise.allSettled() = graceful degradation
// - Show what worked, indicate what failed

Bug #3: Forgotten Cleanup → Memory Leak 💧

jsx
/**
 * 🐛 BUG: Polling continues after unmount
 * 🎯 Nhiệm vụ: Add cleanup
 */

function BuggyLiveStats() {
  const [stats, setStats] = useState(null);

  useEffect(() => {
    // ❌ No cleanup for interval!
    const intervalId = setInterval(async () => {
      const response = await fetch('/api/stats');
      const data = await response.json();
      setStats(data); // ← Continues after unmount!
    }, 5000);

    // ❌ Missing return!
  }, []);

  // ...
}

// 🤔 PROBLEM:
// - Component mounts → Interval starts
// - Component unmounts → Interval KEEPS RUNNING
// - setStats called on unmounted component → Warning
// - Memory leak (interval never cleared)

// ✅ FIX: Cleanup interval
function Fixed() {
  const [stats, setStats] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    const intervalId = setInterval(async () => {
      try {
        const response = await fetch('/api/stats', {
          signal: controller.signal,
        });
        const data = await response.json();
        setStats(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error(err);
        }
      }
    }, 5000);

    return () => {
      clearInterval(intervalId); // ✅ Clear interval
      controller.abort(); // ✅ Abort in-flight request
    };
  }, []);

  // FIXED:
  // - Unmount → Cleanup runs
  // - Interval cleared ✅
  // - Pending request aborted ✅
  // - No memory leak ✅

  // ...
}

// 🎓 BÀI HỌC:
// - Polling/intervals ALWAYS need cleanup
// - Cleanup both interval AND pending requests
// - Test unmount behavior

✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)

Knowledge Check

  • [ ] Tôi hiểu race conditions và tại sao xảy ra
  • [ ] Tôi biết sử dụng AbortController
  • [ ] Tôi phân biệt được sequential vs parallel
  • [ ] Tôi biết khi nào dùng Promise.all vs Promise.allSettled
  • [ ] Tôi có thể implement dependent requests
  • [ ] Tôi handle được stale data issues
  • [ ] Tôi cleanup được all requests properly

🏠 BÀI TẬP VỀ NHÀ

Bắt buộc (30 phút)

Bài 1: GitHub Repos Browser với Abort

  • Search organizations
  • Fetch repos for selected org
  • Cancel old searches
  • AbortController cho both
💡 Solution
jsx
/**
 * GitHub Repos Browser với AbortController
 * - Tìm kiếm organization
 * - Hiển thị danh sách repositories của organization được chọn
 * - Cancel request cũ khi search hoặc chọn org mới
 * - Minimum 2 ký tự để search
 */
import { useState, useEffect } from 'react';

function GitHubReposBrowser() {
  const [searchQuery, setSearchQuery] = useState('');
  const [orgs, setOrgs] = useState([]);
  const [selectedOrg, setSelectedOrg] = useState(null);
  const [repos, setRepos] = useState([]);

  const [orgsLoading, setOrgsLoading] = useState(false);
  const [reposLoading, setReposLoading] = useState(false);
  const [error, setError] = useState(null);

  // Tìm kiếm organizations
  useEffect(() => {
    if (searchQuery.length < 2) {
      setOrgs([]);
      setSelectedOrg(null);
      setRepos([]);
      return;
    }

    const controller = new AbortController();

    async function searchOrgs() {
      setOrgsLoading(true);
      setError(null);

      try {
        const res = await fetch(
          `https://api.github.com/search/users?q=${searchQuery}+type:org`,
          { signal: controller.signal },
        );

        if (!res.ok) {
          throw new Error(`GitHub API error: ${res.status}`);
        }

        const data = await res.json();
        const orgItems = data.items || [];
        setOrgs(orgItems);

        // Nếu chỉ có 1 kết quả → tự động chọn luôn
        if (orgItems.length === 1) {
          setSelectedOrg(orgItems[0].login);
        } else {
          setSelectedOrg(null);
          setRepos([]);
        }
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message || 'Không thể tìm tổ chức');
        }
      } finally {
        setOrgsLoading(false);
      }
    }

    // Debounce nhẹ 400ms
    const timer = setTimeout(() => {
      searchOrgs();
    }, 400);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [searchQuery]);

  // Fetch repositories khi chọn organization
  useEffect(() => {
    if (!selectedOrg) {
      setRepos([]);
      return;
    }

    const controller = new AbortController();

    async function fetchRepos() {
      setReposLoading(true);
      setError(null);

      try {
        const res = await fetch(
          `https://api.github.com/orgs/${selectedOrg}/repos?per_page=30&sort=updated`,
          { signal: controller.signal },
        );

        if (!res.ok) {
          throw new Error(`Không thể tải repositories: ${res.status}`);
        }

        const data = await res.json();
        setRepos(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message || 'Lỗi khi tải repositories');
        }
      } finally {
        setReposLoading(false);
      }
    }

    fetchRepos();

    return () => controller.abort();
  }, [selectedOrg]);

  return (
    <div style={{ maxWidth: '900px', margin: '0 auto', padding: '20px' }}>
      <h2>GitHub Organization Repositories</h2>

      <input
        type='text'
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value.trim())}
        placeholder='Tìm organization (tối thiểu 2 ký tự)...'
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          marginBottom: '16px',
        }}
      />

      {orgsLoading && <p>Đang tìm tổ chức...</p>}
      {error && <p style={{ color: 'red' }}>{error}</p>}

      {orgs.length > 0 && (
        <div style={{ marginBottom: '24px' }}>
          <label
            style={{
              fontWeight: 'bold',
              display: 'block',
              marginBottom: '8px',
            }}
          >
            Kết quả tìm thấy ({orgs.length} tổ chức):
          </label>
          <select
            value={selectedOrg || ''}
            onChange={(e) => setSelectedOrg(e.target.value || null)}
            style={{ width: '100%', padding: '10px', fontSize: '15px' }}
          >
            <option value=''>— Chọn một organization —</option>
            {orgs.map((org) => (
              <option
                key={org.id}
                value={org.login}
              >
                {org.login} {org.name ? `(${org.name})` : ''} —{' '}
                {org.public_repos || '?'} repos
              </option>
            ))}
          </select>
        </div>
      )}

      {selectedOrg && (
        <>
          <h3>Repositories của {selectedOrg}</h3>

          {reposLoading && <p>Đang tải repositories...</p>}

          {repos.length === 0 && !reposLoading && (
            <p style={{ color: '#666' }}>Không có repository công khai nào</p>
          )}

          {repos.length > 0 && (
            <div
              style={{
                maxHeight: '500px',
                overflowY: 'auto',
                border: '1px solid #ddd',
                borderRadius: '6px',
              }}
            >
              {repos.map((repo) => (
                <div
                  key={repo.id}
                  style={{
                    padding: '12px 16px',
                    borderBottom: '1px solid #eee',
                  }}
                >
                  <a
                    href={repo.html_url}
                    target='_blank'
                    rel='noopener noreferrer'
                    style={{
                      fontWeight: 'bold',
                      color: '#0366d6',
                      textDecoration: 'none',
                    }}
                  >
                    {repo.name}
                  </a>
                  <p
                    style={{
                      margin: '6px 0 0 0',
                      color: '#586069',
                      fontSize: '14px',
                    }}
                  >
                    {repo.description || 'Không có mô tả'}
                  </p>
                  <div
                    style={{
                      marginTop: '8px',
                      fontSize: '13px',
                      color: '#586069',
                    }}
                  >
                    ⭐ {repo.stargazers_count} • 🍴 {repo.forks_count} • Ngôn
                    ngữ: {repo.language || '—'}
                  </div>
                </div>
              ))}
            </div>
          )}
        </>
      )}
    </div>
  );
}

export default GitHubReposBrowser;

/* Kết quả ví dụ khi test:
- Gõ "facebook" → thấy Facebook, facebookexperimental, facebookarchive...
- Chọn "facebook" → load ~30 repo mới nhất (React, create-react-app, jest, relay...)
- Gõ nhanh "micro" → "microsoft" → "mozilla" → chỉ request cuối cùng hoàn thành
- Console thấy các thông báo abort cho request cũ
- Chuyển tổ chức nhanh → repo cũ bị hủy, không bị lẫn dữ liệu
*/

Bài 2: Weather + Forecast (Dependent)

  • Fetch current weather by city
  • Fetch 5-day forecast (depends on city coords)
  • Sequential fetching
  • Error handling per step
💡 Solution
jsx
/**
 * Weather + Forecast Viewer (Dependent Requests)
 * - Nhập tên thành phố → fetch thời tiết hiện tại
 * - Nếu thành công → tự động fetch dự báo 5 ngày (dựa trên tọa độ)
 * - Sử dụng AbortController cho cả 2 request
 * - Xử lý lỗi độc lập cho từng bước
 * - Hiển thị loading và error riêng biệt
 */

// ⚠️ Chỉ dùng hard-code cho dev/test.
// Trên production, API_KEY phải được lấy từ biến môi trường (env)
const API_KEY = 'a9e8470d6cb139f4ac9635219a59a7e1';

import { useEffect, useState } from 'react';

function WeatherWithForecast() {
  const [city, setCity] = useState('');
  const [currentWeather, setCurrentWeather] = useState(null);
  const [forecast, setForecast] = useState([]);

  const [currentLoading, setCurrentLoading] = useState(false);
  const [forecastLoading, setForecastLoading] = useState(false);

  const [currentError, setCurrentError] = useState(null);
  const [forecastError, setForecastError] = useState(null);

  // Fetch current weather
  useEffect(() => {
    if (!city.trim()) {
      setCurrentWeather(null);
      setForecast([]);
      setCurrentError(null);
      setForecastError(null);
      return;
    }

    const controller = new AbortController();

    async function fetchCurrent() {
      setCurrentLoading(true);
      setCurrentError(null);
      setForecast([]); // reset forecast khi tìm thành phố mới

      try {
        const res = await fetch(
          `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=metric&appid=${API_KEY}`,
          { signal: controller.signal },
        );

        if (!res.ok) {
          throw new Error(
            res.status === 404
              ? 'Không tìm thấy thành phố'
              : 'Lỗi API thời tiết',
          );
        }

        const data = await res.json();
        setCurrentWeather(data);
        setCurrentLoading(false);

        // Không fetch forecast ở đây → để effect phụ thuộc vào currentWeather xử lý
      } catch (err) {
        if (err.name !== 'AbortError') {
          setCurrentError(err.message);
          setCurrentLoading(false);
        }
      }
    }

    // Debounce nhẹ 500ms để tránh gọi API liên tục khi gõ
    const timer = setTimeout(() => {
      fetchCurrent();
    }, 500);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [city]);

  // Fetch forecast (dependent - chỉ chạy khi có currentWeather)
  useEffect(() => {
    if (!currentWeather?.coord) {
      setForecast([]);
      setForecastError(null);
      return;
    }

    const controller = new AbortController();

    async function fetchForecast() {
      setForecastLoading(true);
      setForecastError(null);

      try {
        const { lat, lon } = currentWeather.coord;
        const res = await fetch(
          `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&cnt=40&appid=${API_KEY}`,
          { signal: controller.signal },
        );

        if (!res.ok) {
          throw new Error('Không thể tải dự báo');
        }

        const data = await res.json();
        // Lấy 1 forecast mỗi ngày (khoảng 8 item/ngày → chọn item đầu mỗi ngày)
        const daily = [];
        for (let i = 0; i < data.list.length; i += 8) {
          daily.push(data.list[i]);
        }
        setForecast(daily.slice(0, 5)); // tối đa 5 ngày
      } catch (err) {
        if (err.name !== 'AbortError') {
          setForecastError(err.message);
        }
      } finally {
        setForecastLoading(false);
      }
    }

    fetchForecast();

    return () => controller.abort();
  }, [currentWeather]);

  const formatDate = (timestamp) => {
    return new Date(timestamp * 1000).toLocaleDateString('vi-VN', {
      weekday: 'short',
      day: 'numeric',
      month: 'short',
    });
  };

  return (
    <div style={{ maxWidth: '700px', margin: '0 auto', padding: '20px' }}>
      <h2>Thời tiết & Dự báo 5 ngày</h2>

      <input
        type='text'
        value={city}
        onChange={(e) => setCity(e.target.value)}
        placeholder='Nhập tên thành phố (ví dụ: Hanoi, Saigon, Tokyo)...'
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          marginBottom: '20px',
        }}
      />

      {/* Current Weather */}
      <div style={{ marginBottom: '30px' }}>
        <h3>
          Thời tiết hiện tại{' '}
          {currentWeather ? `tại ${currentWeather.name}` : ''}
        </h3>

        {currentLoading && <p>Đang tải thời tiết hiện tại...</p>}
        {currentError && <p style={{ color: 'red' }}>{currentError}</p>}

        {currentWeather && !currentLoading && (
          <div
            style={{
              padding: '16px',
              background: '#e3f2fd',
              borderRadius: '8px',
              display: 'flex',
              alignItems: 'center',
              gap: '20px',
            }}
          >
            <img
              src={`https://openweathermap.org/img/wn/${currentWeather.weather[0].icon}@2x.png`}
              alt={currentWeather.weather[0].description}
              style={{ width: '80px' }}
            />
            <div>
              <div style={{ fontSize: '32px', fontWeight: 'bold' }}>
                {Math.round(currentWeather.main.temp)}°C
              </div>
              <div style={{ fontSize: '18px', textTransform: 'capitalize' }}>
                {currentWeather.weather[0].description}
              </div>
              <div style={{ color: '#555', marginTop: '8px' }}>
                Cảm giác: {Math.round(currentWeather.main.feels_like)}°C • Độ
                ẩm: {currentWeather.main.humidity}% • Gió:{' '}
                {currentWeather.wind.speed} m/s
              </div>
            </div>
          </div>
        )}
      </div>

      {/* Forecast */}
      {currentWeather && (
        <div>
          <h3>Dự báo 5 ngày</h3>

          {forecastLoading && <p>Đang tải dự báo...</p>}
          {forecastError && <p style={{ color: 'red' }}>{forecastError}</p>}

          {!forecastLoading && !forecastError && forecast.length > 0 && (
            <div
              style={{
                display: 'grid',
                gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
                gap: '12px',
              }}
            >
              {forecast.map((day, index) => (
                <div
                  key={index}
                  style={{
                    padding: '12px',
                    background: index === 0 ? '#fff3e0' : '#f5f5f5',
                    borderRadius: '8px',
                    textAlign: 'center',
                  }}
                >
                  <div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
                    {index === 0 ? 'Hôm nay' : formatDate(day.dt)}
                  </div>
                  <img
                    src={`https://openweathermap.org/img/wn/${day.weather[0].icon}.png`}
                    alt={day.weather[0].description}
                    style={{ width: '50px' }}
                  />
                  <div
                    style={{
                      fontSize: '20px',
                      fontWeight: 'bold',
                      margin: '4px 0',
                    }}
                  >
                    {Math.round(day.main.temp)}°C
                  </div>
                  <div
                    style={{
                      fontSize: '14px',
                      color: '#555',
                      textTransform: 'capitalize',
                    }}
                  >
                    {day.weather[0].description}
                  </div>
                </div>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

export default WeatherWithForecast;

/* Kết quả ví dụ khi test:
- Gõ "Hanoi" → sau ~500ms thấy thời tiết hiện tại (nhiệt độ, cảm giác, độ ẩm, gió...)
- Tự động load dự báo 5 ngày → hiển thị lưới 5 ngày (hôm nay + 4 ngày tới)
- Gõ nhanh "Saigon" → "Tokyo" → request cũ bị abort, chỉ hiển thị dữ liệu Tokyo
- Console có log abort khi gõ nhanh hoặc đổi thành phố
- Nhập thành phố không tồn tại → hiển thị lỗi "Không tìm thấy thành phố" mà không crash forecast
- Không có thành phố → không hiển thị forecast
*/

Nâng cao (60 phút)

Bài 3: Reddit Clone Feed

  • Infinite scroll
  • Real-time score updates (polling)
  • Filter by subreddit
  • Abort all on filter change
💡 Solution
jsx
/**
 * Reddit Clone Feed
 * - Infinite scroll với IntersectionObserver
 * - Real-time polling cập nhật score mỗi 10s
 * - Filter by subreddit với AbortController
 * - Vote system (upvote/downvote)
 * - Abort all requests khi filter change
 */
import { useState, useEffect, useRef, useCallback } from 'react';

const API_BASE = 'https://jsonplaceholder.typicode.com';
const POSTS_PER_PAGE = 10;

// Mock subreddits
const SUBREDDITS = [
  { id: 'all', name: 'All', color: '#FF4500' },
  { id: 'react', name: 'r/react', color: '#61DAFB' },
  { id: 'javascript', name: 'r/javascript', color: '#F7DF1E' },
  { id: 'webdev', name: 'r/webdev', color: '#4CAF50' },
  { id: 'programming', name: 'r/programming', color: '#9C27B0' },
];

// Simulate Reddit post structure
function enhancePost(post) {
  return {
    ...post,
    subreddit: SUBREDDITS[Math.floor(Math.random() * SUBREDDITS.length)],
    score: Math.floor(Math.random() * 10000) + 100,
    comments: Math.floor(Math.random() * 500) + 10,
    author: `u/user${post.userId}`,
    timestamp: Date.now() - Math.random() * 86400000 * 7, // Last 7 days
    awards: Math.floor(Math.random() * 5),
  };
}

function RedditCloneFeed() {
  // ==================== STATE ====================

  const [selectedSubreddit, setSelectedSubreddit] = useState('all');
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);

  // Loading states
  const [initialLoading, setInitialLoading] = useState(true);
  const [loadingMore, setLoadingMore] = useState(false);
  const [error, setError] = useState(null);

  // Polling state
  const [polling, setPolling] = useState(true);
  const [lastUpdate, setLastUpdate] = useState(Date.now());

  // ==================== REFS ====================

  const observerRef = useRef(null);
  const abortControllersRef = useRef({
    initial: null,
    loadMore: null,
    polling: null,
  });

  // ==================== INTERSECTION OBSERVER ====================

  const lastPostRef = (node) => {
    if (loadingMore) return;

    if (observerRef.current) {
      observerRef.current.disconnect();
    }

    observerRef.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !initialLoading) {
          loadMorePosts();
        }
      },
      { threshold: 1.0 },
    );

    if (node) {
      observerRef.current.observe(node);
    }
  };

  // ==================== DATA FETCHING ====================

  // Initial load (when component mounts or filter changes)
  useEffect(() => {
    // Abort all previous requests
    Object.values(abortControllersRef.current).forEach((ctrl) => {
      if (ctrl) ctrl.abort();
    });

    const controller = new AbortController();
    abortControllersRef.current.initial = controller;

    async function loadInitialPosts() {
      try {
        setInitialLoading(true);
        setError(null);
        setPosts([]);
        setPage(1);
        setHasMore(true);

        console.log(`🔄 Loading initial posts for: ${selectedSubreddit}`);

        const response = await fetch(
          `${API_BASE}/posts?_page=1&_limit=${POSTS_PER_PAGE}`,
          { signal: controller.signal },
        );

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const data = await response.json();

        // Enhance posts with Reddit-like data
        let enhancedPosts = data.map(enhancePost);

        // Filter by subreddit if not "all"
        if (selectedSubreddit !== 'all') {
          enhancedPosts = enhancedPosts.filter(
            (post) => post.subreddit.id === selectedSubreddit,
          );
        }

        setPosts(enhancedPosts);
        setInitialLoading(false);

        console.log(`✅ Loaded ${enhancedPosts.length} posts`);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('❌ Failed to load posts:', err);
          setError(err.message || 'Failed to load posts');
          setInitialLoading(false);
        }
      }
    }

    loadInitialPosts();

    return () => {
      controller.abort();
    };
  }, [selectedSubreddit]);

  // Load more posts (infinite scroll)
  async function loadMorePosts() {
    if (loadingMore || !hasMore || initialLoading) return;

    // Abort previous loadMore request if any
    if (abortControllersRef.current.loadMore) {
      abortControllersRef.current.loadMore.abort();
    }

    const controller = new AbortController();
    abortControllersRef.current.loadMore = controller;

    try {
      setLoadingMore(true);

      const nextPage = page + 1;
      console.log(`📥 Loading more posts (page ${nextPage})...`);

      const response = await fetch(
        `${API_BASE}/posts?_page=${nextPage}&_limit=${POSTS_PER_PAGE}`,
        { signal: controller.signal },
      );

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();

      if (data.length === 0) {
        setHasMore(false);
        console.log('🏁 No more posts to load');
        return;
      }

      // Enhance and filter posts
      let enhancedPosts = data.map(enhancePost);

      if (selectedSubreddit !== 'all') {
        enhancedPosts = enhancedPosts.filter(
          (post) => post.subreddit.id === selectedSubreddit,
        );
      }

      setPosts((prev) => [...prev, ...enhancedPosts]);
      setPage(nextPage);

      console.log(`✅ Loaded ${enhancedPosts.length} more posts`);
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error('❌ Failed to load more:', err);
        // Don't show error, just stop loading
      }
    } finally {
      setLoadingMore(false);
    }
  }

  // Polling: Update scores in real-time
  useEffect(() => {
    if (!polling || posts.length === 0) return;

    const controller = new AbortController();
    abortControllersRef.current.polling = controller;

    const intervalId = setInterval(() => {
      console.log('🔄 Polling: Updating scores...');

      // Simulate score changes
      setPosts((prevPosts) =>
        prevPosts.map((post) => ({
          ...post,
          score: post.score + Math.floor(Math.random() * 20) - 5, // +/- 5
          comments: post.comments + Math.floor(Math.random() * 3),
        })),
      );

      setLastUpdate(Date.now());
    }, 10000); // Every 10 seconds

    return () => {
      clearInterval(intervalId);
      controller.abort();
    };
  }, [polling, posts.length]);

  // ==================== CLEANUP ON UNMOUNT ====================

  useEffect(() => {
    return () => {
      // Abort all requests on unmount
      Object.values(abortControllersRef.current).forEach((ctrl) => {
        if (ctrl) ctrl.abort();
      });

      // Disconnect observer
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, []);

  // ==================== HELPERS ====================

  const formatScore = (score) => {
    if (score >= 1000) {
      return `${(score / 1000).toFixed(1)}k`;
    }
    return score;
  };

  const formatTime = (timestamp) => {
    const diff = Date.now() - timestamp;
    const hours = Math.floor(diff / 3600000);
    const days = Math.floor(hours / 24);

    if (days > 0) return `${days}d ago`;
    if (hours > 0) return `${hours}h ago`;
    return 'just now';
  };

  const formatLastUpdate = () => {
    const seconds = Math.floor((Date.now() - lastUpdate) / 1000);
    return `${seconds}s ago`;
  };

  // ==================== EVENT HANDLERS ====================

  const handleSubredditChange = (subredditId) => {
    if (subredditId === selectedSubreddit) return;

    console.log(`🔀 Changing subreddit to: ${subredditId}`);
    setSelectedSubreddit(subredditId);
  };

  const handleTogglePolling = () => {
    setPolling((prev) => !prev);
    console.log(`${polling ? '⏸️ Paused' : '▶️ Started'} polling`);
  };

  const handleUpvote = (postId) => {
    setPosts((prevPosts) =>
      prevPosts.map((post) =>
        post.id === postId ? { ...post, score: post.score + 1 } : post,
      ),
    );
  };

  const handleDownvote = (postId) => {
    setPosts((prevPosts) =>
      prevPosts.map((post) =>
        post.id === postId ? { ...post, score: post.score - 1 } : post,
      ),
    );
  };

  // ==================== RENDER ====================

  if (initialLoading) {
    return (
      <div style={styles.loadingContainer}>
        <div style={styles.spinner}>⏳</div>
        <p>Loading posts...</p>
      </div>
    );
  }

  if (error) {
    return (
      <div style={styles.errorContainer}>
        <div style={{ fontSize: '48px', marginBottom: '16px' }}>❌</div>
        <p style={{ color: '#d32f2f', fontSize: '18px' }}>Error: {error}</p>
        <button
          onClick={() => setSelectedSubreddit('all')}
          style={styles.retryButton}
        >
          Try Again
        </button>
      </div>
    );
  }

  return (
    <div style={styles.container}>
      {/* Header */}
      <div style={styles.header}>
        <div style={styles.headerLeft}>
          <h1 style={styles.title}>🔴 Reddit Clone</h1>
          <div style={styles.pollingIndicator}>
            <label style={styles.pollingLabel}>
              <input
                type='checkbox'
                checked={polling}
                onChange={handleTogglePolling}
                style={{ marginRight: '8px' }}
              />
              Live updates
              {polling && (
                <span style={styles.lastUpdate}>
                  (updated {formatLastUpdate()})
                </span>
              )}
            </label>
          </div>
        </div>
      </div>

      {/* Subreddit Filter */}
      <div style={styles.subredditBar}>
        {SUBREDDITS.map((sub) => (
          <button
            key={sub.id}
            onClick={() => handleSubredditChange(sub.id)}
            style={{
              ...styles.subredditButton,
              ...(selectedSubreddit === sub.id
                ? {
                    background: sub.color,
                    color: 'white',
                    fontWeight: 'bold',
                  }
                : {}),
            }}
          >
            {sub.name}
          </button>
        ))}
      </div>

      {/* Posts Feed */}
      <div style={styles.feed}>
        {posts.length === 0 ? (
          <div style={styles.emptyState}>
            <div style={{ fontSize: '48px', marginBottom: '16px' }}>📭</div>
            <p>No posts found in {selectedSubreddit}</p>
          </div>
        ) : (
          <>
            {posts.map((post, index) => {
              const isLast = index === posts.length - 1;

              return (
                <div
                  key={post.id}
                  ref={isLast ? lastPostRef : null}
                  style={styles.postCard}
                >
                  {/* Vote Section */}
                  <div style={styles.voteSection}>
                    <button
                      onClick={() => handleUpvote(post.id)}
                      style={styles.voteButton}
                    >
                      ⬆️
                    </button>
                    <div style={styles.score}>{formatScore(post.score)}</div>
                    <button
                      onClick={() => handleDownvote(post.id)}
                      style={styles.voteButton}
                    >
                      ⬇️
                    </button>
                  </div>

                  {/* Content Section */}
                  <div style={styles.contentSection}>
                    <div style={styles.postMeta}>
                      <span
                        style={{
                          ...styles.subredditTag,
                          background: post.subreddit.color,
                        }}
                      >
                        {post.subreddit.name}
                      </span>
                      <span style={styles.author}>{post.author}</span>
                      <span style={styles.timestamp}>
                        • {formatTime(post.timestamp)}
                      </span>
                      {post.awards > 0 && (
                        <span style={styles.awards}>🏆 {post.awards}</span>
                      )}
                    </div>

                    <h3 style={styles.postTitle}>{post.title}</h3>
                    <p style={styles.postBody}>{post.body}</p>

                    <div style={styles.postActions}>
                      <span style={styles.actionButton}>
                        💬 {post.comments} comments
                      </span>
                      <span style={styles.actionButton}>🔗 Share</span>
                      <span style={styles.actionButton}>💾 Save</span>
                    </div>
                  </div>
                </div>
              );
            })}
          </>
        )}

        {/* Loading More Indicator */}
        {loadingMore && (
          <div style={styles.loadingMore}>
            <div style={styles.spinner}>⏳</div>
            <p>Loading more posts...</p>
          </div>
        )}

        {/* End of Feed */}
        {!hasMore && posts.length > 0 && (
          <div style={styles.endOfFeed}>
            <p>🏁 You've reached the end!</p>
            <p style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>
              Total posts loaded: {posts.length}
            </p>
          </div>
        )}
      </div>

      {/* Debug Info */}
      <div style={styles.debugInfo}>
        <h4>🔧 Debug Info:</h4>
        <ul>
          <li>Subreddit: {selectedSubreddit}</li>
          <li>Posts loaded: {posts.length}</li>
          <li>Current page: {page}</li>
          <li>Has more: {hasMore ? 'Yes' : 'No'}</li>
          <li>Polling: {polling ? 'Active' : 'Paused'}</li>
          <li>Loading more: {loadingMore ? 'Yes' : 'No'}</li>
        </ul>
      </div>
    </div>
  );
}

// ==================== STYLES ====================

const styles = {
  container: {
    maxWidth: '800px',
    margin: '0 auto',
    padding: '20px',
    background: '#DAE0E6',
    minHeight: '100vh',
  },

  header: {
    background: 'white',
    padding: '16px 20px',
    borderRadius: '8px',
    marginBottom: '16px',
    boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'center',
  },

  headerLeft: {
    flex: 1,
  },

  title: {
    margin: '0 0 8px 0',
    fontSize: '24px',
    fontWeight: 'bold',
    color: '#FF4500',
  },

  pollingIndicator: {
    fontSize: '14px',
  },

  pollingLabel: {
    display: 'flex',
    alignItems: 'center',
    color: '#666',
  },

  lastUpdate: {
    marginLeft: '8px',
    fontSize: '12px',
    color: '#999',
  },

  subredditBar: {
    background: 'white',
    padding: '12px',
    borderRadius: '8px',
    marginBottom: '16px',
    display: 'flex',
    gap: '8px',
    flexWrap: 'wrap',
    boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
  },

  subredditButton: {
    padding: '8px 16px',
    border: '2px solid #ddd',
    borderRadius: '20px',
    background: 'white',
    cursor: 'pointer',
    fontSize: '14px',
    transition: 'all 0.2s',
  },

  feed: {
    display: 'flex',
    flexDirection: 'column',
    gap: '12px',
  },

  postCard: {
    background: 'white',
    borderRadius: '8px',
    padding: '12px',
    display: 'flex',
    gap: '12px',
    boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
    transition: 'transform 0.2s',
  },

  voteSection: {
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
    gap: '4px',
    minWidth: '40px',
  },

  voteButton: {
    background: 'none',
    border: 'none',
    cursor: 'pointer',
    fontSize: '18px',
    padding: '4px',
    transition: 'transform 0.1s',
  },

  score: {
    fontWeight: 'bold',
    fontSize: '14px',
    color: '#1A1A1B',
  },

  contentSection: {
    flex: 1,
    minWidth: 0,
  },

  postMeta: {
    display: 'flex',
    alignItems: 'center',
    gap: '8px',
    marginBottom: '8px',
    fontSize: '12px',
    flexWrap: 'wrap',
  },

  subredditTag: {
    padding: '2px 8px',
    borderRadius: '12px',
    color: 'white',
    fontWeight: 'bold',
    fontSize: '11px',
  },

  author: {
    color: '#1A1A1B',
    fontWeight: '500',
  },

  timestamp: {
    color: '#7C7C7C',
  },

  awards: {
    marginLeft: 'auto',
  },

  postTitle: {
    margin: '0 0 8px 0',
    fontSize: '18px',
    fontWeight: '500',
    color: '#1A1A1B',
  },

  postBody: {
    margin: '0 0 12px 0',
    fontSize: '14px',
    color: '#1A1A1B',
    lineHeight: '1.4',
  },

  postActions: {
    display: 'flex',
    gap: '16px',
  },

  actionButton: {
    fontSize: '12px',
    color: '#7C7C7C',
    cursor: 'pointer',
    fontWeight: '500',
  },

  loadingMore: {
    textAlign: 'center',
    padding: '40px 20px',
    color: '#666',
  },

  endOfFeed: {
    textAlign: 'center',
    padding: '40px 20px',
    color: '#666',
    background: 'white',
    borderRadius: '8px',
    marginTop: '20px',
  },

  emptyState: {
    textAlign: 'center',
    padding: '80px 20px',
    color: '#666',
    background: 'white',
    borderRadius: '8px',
  },

  loadingContainer: {
    height: '100vh',
    display: 'flex',
    flexDirection: 'column',
    justifyContent: 'center',
    alignItems: 'center',
    background: '#DAE0E6',
  },

  errorContainer: {
    height: '100vh',
    display: 'flex',
    flexDirection: 'column',
    justifyContent: 'center',
    alignItems: 'center',
    background: '#DAE0E6',
  },

  retryButton: {
    marginTop: '16px',
    padding: '12px 24px',
    background: '#FF4500',
    color: 'white',
    border: 'none',
    borderRadius: '8px',
    cursor: 'pointer',
    fontSize: '16px',
  },

  spinner: {
    fontSize: '32px',
    animation: 'spin 1.5s linear infinite',
    marginBottom: '16px',
  },

  debugInfo: {
    marginTop: '30px',
    padding: '20px',
    background: 'white',
    borderRadius: '8px',
    boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
  },
};

export default RedditCloneFeed;

/* Kết quả ví dụ khi test:
- Mở trang → load 10 posts đầu tiên với score, comments, vote buttons
- Scroll xuống cuối → IntersectionObserver trigger → load thêm 10 posts
- Click "r/react" → abort old requests → fetch lại posts filtered theo r/react
- Đợi 10s với polling bật → score tự động thay đổi (+/- random), "updated Xs ago" update
- Click ⬆️ trên post → score +1 ngay lập tức
- Click ⬇️ → score -1
- Uncheck "Live updates" → polling dừng, score không thay đổi
- Check lại → polling tiếp tục
- Switch nhanh giữa "All" → "r/javascript" → "r/webdev" → chỉ request cuối cùng hoàn thành
- Scroll đến hết → thấy "🏁 You've reached the end!" + tổng số posts
- Console log:
  "🔄 Loading initial posts for: react"
  "✅ Loaded 10 posts"
  "📥 Loading more posts (page 2)..."
  "✅ Loaded 10 more posts"
  "🔄 Polling: Updating scores..."
  "🔀 Changing subreddit to: javascript"
*/

Bài 4: Multi-Currency Converter

  • Parallel fetch multiple exchange rates
  • Promise.allSettled
  • Show partial data
  • Refresh individual rates
💡 Solution
jsx
/**
 * Multi-Currency Converter
 * - Chuyển đổi đồng thời nhiều cặp tiền tệ (USD → EUR, GBP, JPY, VND, ...)
 * - Sử dụng Promise.allSettled để fetch song song, chịu lỗi từng API riêng
 * - Hiển thị kết quả từng cặp độc lập (partial success)
 * - Refresh từng cặp hoặc refresh tất cả
 * - AbortController cho mọi request
 * - Input số tiền (base amount) và base currency (mặc định USD)
 */

// ⚠️ Chỉ dùng hard-code cho dev/test.
// Trên production, API_KEY phải được lấy từ biến môi trường (env)
// Thay bằng key thật từ exchangerate-api.com hoặc tương tự
const API_KEY = '2ceead6e2785c152ede16844';
const API_BASE = `https://v6.exchangerate-api.com/v6/${API_KEY}/latest`;

import { useState, useEffect } from 'react';

function MultiCurrencyConverter() {
  const [baseAmount, setBaseAmount] = useState(100);
  const [baseCurrency, setBaseCurrency] = useState('USD');

  const targetCurrencies = [
    'EUR',
    'GBP',
    'JPY',
    'VND',
    'CAD',
    'AUD',
    'CHF',
    'CNY',
  ];

  const [rates, setRates] = useState({});
  const [loadingStates, setLoadingStates] = useState({});
  const [errors, setErrors] = useState({});

  const fetchRates = async (abortSignal) => {
    const newLoading = {};
    const newErrors = {};
    targetCurrencies.forEach((c) => {
      newLoading[c] = true;
      newErrors[c] = null;
    });
    setLoadingStates(newLoading);
    setErrors(newErrors);

    const promises = targetCurrencies.map(async (target) => {
      try {
        const res = await fetch(`${API_BASE}/${baseCurrency}`, {
          signal: abortSignal,
        });

        if (!res.ok) {
          throw new Error(`HTTP ${res.status}`);
        }

        const data = await res.json();

        if (data.result !== 'success') {
          throw new Error(data['error-type'] || 'API error');
        }

        const rate = data.conversion_rates?.[target];
        if (!rate) {
          throw new Error(`Không có tỷ giá cho ${target}`);
        }

        return { target, rate, success: true };
      } catch (err) {
        if (err.name === 'AbortError') {
          return { target, aborted: true };
        }
        return {
          target,
          error: err.message || 'Lỗi không xác định',
          success: false,
        };
      }
    });

    const results = await Promise.allSettled(promises);

    const newRates = {};
    const finalLoading = {};
    const finalErrors = {};

    results.forEach((result, index) => {
      const target = targetCurrencies[index];

      if (result.status === 'fulfilled') {
        const { success, rate, error, aborted } = result.value;

        if (aborted) {
          // Không cập nhật nếu bị abort
          finalLoading[target] = false;
          return;
        }

        if (success) {
          newRates[target] = rate;
          finalErrors[target] = null;
        } else {
          finalErrors[target] = error;
        }
      } else {
        finalErrors[target] = result.reason?.message || 'Promise rejected';
      }

      finalLoading[target] = false;
    });

    setRates((prev) => ({ ...prev, ...newRates }));
    setLoadingStates((prev) => ({ ...prev, ...finalLoading }));
    setErrors((prev) => ({ ...prev, ...finalErrors }));
  };

  useEffect(() => {
    const controller = new AbortController();

    fetchRates(controller.signal);

    return () => controller.abort();
  }, [baseCurrency]);

  const handleRefreshAll = () => {
    const controller = new AbortController();
    fetchRates(controller.signal);
  };

  const handleRefreshOne = (target) => {
    const controller = new AbortController();

    async function refreshSingle() {
      setLoadingStates((prev) => ({ ...prev, [target]: true }));
      setErrors((prev) => ({ ...prev, [target]: null }));

      try {
        const res = await fetch(`${API_BASE}/${baseCurrency}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);

        const data = await res.json();
        if (data.result !== 'success')
          throw new Error(data['error-type'] || 'API error');

        const rate = data.conversion_rates?.[target];
        if (!rate) throw new Error(`Không có tỷ giá`);

        setRates((prev) => ({ ...prev, [target]: rate }));
        setErrors((prev) => ({ ...prev, [target]: null }));
      } catch (err) {
        if (err.name !== 'AbortError') {
          setErrors((prev) => ({ ...prev, [target]: err.message }));
        }
      } finally {
        setLoadingStates((prev) => ({ ...prev, [target]: false }));
      }
    }

    refreshSingle();

    return () => controller.abort();
  };

  const formatNumber = (num) => {
    return num.toLocaleString('vi-VN', {
      minimumFractionDigits: 2,
      maximumFractionDigits: 4,
    });
  };

  return (
    <div style={{ maxWidth: '900px', margin: '0 auto', padding: '20px' }}>
      <h2>Multi-Currency Converter</h2>

      <div
        style={{
          display: 'flex',
          gap: '16px',
          marginBottom: '24px',
          alignItems: 'flex-end',
        }}
      >
        <div style={{ flex: 1 }}>
          <label
            style={{
              display: 'block',
              marginBottom: '6px',
              fontWeight: 'bold',
            }}
          >
            Số tiền
          </label>
          <input
            type='number'
            value={baseAmount}
            onChange={(e) => setBaseAmount(Number(e.target.value) || 0)}
            min='0'
            step='any'
            style={{ width: '100%', padding: '10px', fontSize: '16px' }}
          />
        </div>

        <div style={{ flex: 1 }}>
          <label
            style={{
              display: 'block',
              marginBottom: '6px',
              fontWeight: 'bold',
            }}
          >
            Từ đơn vị
          </label>
          <select
            value={baseCurrency}
            onChange={(e) => setBaseCurrency(e.target.value)}
            style={{ width: '100%', padding: '10px', fontSize: '16px' }}
          >
            <option value='USD'>USD - US Dollar</option>
            <option value='EUR'>EUR - Euro</option>
            <option value='GBP'>GBP - British Pound</option>
            <option value='JPY'>JPY - Japanese Yen</option>
            <option value='VND'>VND - Vietnamese Dong</option>
            <option value='CAD'>CAD - Canadian Dollar</option>
            <option value='AUD'>AUD - Australian Dollar</option>
            <option value='CHF'>CHF - Swiss Franc</option>
            <option value='CNY'>CNY - Chinese Yuan</option>
          </select>
        </div>

        <button
          onClick={handleRefreshAll}
          disabled={Object.values(loadingStates).some(Boolean)}
          style={{
            padding: '10px 20px',
            background: '#4CAF50',
            color: 'white',
            border: 'none',
            borderRadius: '6px',
            cursor: 'pointer',
          }}
        >
          Refresh All
        </button>
      </div>

      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
          gap: '16px',
        }}
      >
        {targetCurrencies.map((target) => {
          const rate = rates[target];
          const converted = baseAmount * (rate || 0);
          const isLoading = loadingStates[target];
          const err = errors[target];

          return (
            <div
              key={target}
              style={{
                padding: '16px',
                border: '1px solid #ddd',
                borderRadius: '8px',
                background: err ? '#fff5f5' : rate ? '#f9fff9' : '#f8f9fa',
              }}
            >
              <div
                style={{
                  fontSize: '18px',
                  fontWeight: 'bold',
                  marginBottom: '8px',
                }}
              >
                {baseCurrency} → {target}
              </div>

              {isLoading ? (
                <div>Đang tải tỷ giá...</div>
              ) : err ? (
                <div style={{ color: 'red' }}>
                  {err}
                  <button
                    onClick={() => handleRefreshOne(target)}
                    style={{
                      marginLeft: '12px',
                      padding: '4px 12px',
                      fontSize: '12px',
                      background: '#ff9800',
                      color: 'white',
                      border: 'none',
                      borderRadius: '4px',
                      cursor: 'pointer',
                    }}
                  >
                    Thử lại
                  </button>
                </div>
              ) : rate ? (
                <>
                  <div
                    style={{
                      fontSize: '28px',
                      fontWeight: 'bold',
                      color: '#2e7d32',
                    }}
                  >
                    {formatNumber(converted)} {target}
                  </div>
                  <div style={{ color: '#555', marginTop: '4px' }}>
                    1 {baseCurrency} = {formatNumber(rate)} {target}
                  </div>
                </>
              ) : (
                <div>Chưa có dữ liệu</div>
              )}

              {!isLoading && !err && rate && (
                <button
                  onClick={() => handleRefreshOne(target)}
                  style={{
                    marginTop: '12px',
                    padding: '6px 12px',
                    fontSize: '13px',
                    background: '#2196F3',
                    color: 'white',
                    border: 'none',
                    borderRadius: '4px',
                    cursor: 'pointer',
                  }}
                >
                  Refresh {target}
                </button>
              )}
            </div>
          );
        })}
      </div>

      <div style={{ marginTop: '24px', color: '#666', fontSize: '14px' }}>
        <p>
          Lưu ý: Dữ liệu tỷ giá lấy từ exchangerate-api.com (cập nhật hàng
          ngày).
        </p>
        <p>API miễn phí có giới hạn → thay key thật để tránh lỗi quota.</p>
      </div>
    </div>
  );
}

export default MultiCurrencyConverter;

/* Kết quả ví dụ khi test:
- Mở component → thấy 8 thẻ chuyển đổi từ USD sang EUR, GBP, JPY, VND... tự động fetch song song
- Một vài cặp thành công → hiển thị số tiền quy đổi (ví dụ: 100 USD ≈ 92.5 EUR)
- Một cặp lỗi (giả sử API quota hoặc mạng) → thẻ đó hiện lỗi đỏ + nút "Thử lại"
- Đổi baseCurrency sang EUR → tất cả thẻ refresh lại với base EUR
- Nhập baseAmount = 500 → tất cả kết quả tự động cập nhật
- Nhấn "Refresh EUR" → chỉ fetch lại tỷ giá EUR, các cặp khác giữ nguyên
- Nhấn "Refresh All" → fetch lại toàn bộ
- Đổi nhanh base currency nhiều lần → request cũ bị abort, không bị lẫn kết quả
*/

📚 TÀI LIỆU THAM KHẢO

  1. AbortController MDN

  2. Promise.allSettled


🔗 KẾT NỐI KIẾN THỨC

Đã học:

  • Ngày 18: Cleanup
  • Ngày 19: Basic fetching

Hướng tới:

  • Ngày 21: useRef
  • Ngày 24: Custom hooks (useAbortableFetch)

🎉 Chúc mừng! Bạn đã hoàn thành Ngày 20!

Bạn đã master:

  • ✅ Race conditions
  • ✅ AbortController
  • ✅ Dependent requests
  • ✅ Parallel optimization
  • ✅ Production-ready patterns

Phase 2 hoàn thành! Ngày mai: useRef fundamentals! 🚀

useEffect — Tham chiếu tư duy cho Senior

Ngày 16–20 | Side Effects → Dependencies → Cleanup → Data Fetching Basic → Advanced
Senior không nhớ code, họ nhớ concepts, mental models và khi nào dùng gì.


MỤC LỤC

  1. Bản đồ tổng thể
  2. Side Effects — Nhận dạng & phân loại
  3. 3 Patterns Dependencies — Bảng quyết định
  4. Cleanup — Khi nào & cleanup gì
  5. Data Fetching — Từ basic đến production
  6. Advanced Patterns — Race Condition & Cancel
  7. Dạng bài tập & nhận dạng vấn đề
  8. Anti-patterns cần tránh
  9. Interview Questions — Theo level
  10. War Stories — Bài học thực tế
  11. Decision Framework nhanh

1. Bản đồ tổng thể

useEffect Introduction (N16)
    ↓  Side effects là gì, chạy khi nào, khác event handler
useEffect Dependencies (N17)
    ↓  Kiểm soát KHI NÀO effect chạy, stale closure
Cleanup & Memory Leaks (N18)
    ↓  Dọn dẹp resources, tránh leak
Data Fetching Basic (N19)
    ↓  Loading/Error/Success pattern, async trong effect
Data Fetching Advanced (N20)
    ↓  Race conditions, AbortController, parallel/dependent requests

Triết lý xuyên suốt:

  • useEffect = "Synchronize component với thế giới bên ngoài"
  • Effect chạy SAU khi DOM đã paint → không block UI
  • Mỗi effect nên có 1 mục đích duy nhất
  • Cleanup là bắt buộc nếu effect tạo resource

2. Side Effects

Side Effect là gì?

Bất kỳ thao tác nào ảnh hưởng đến thứ bên ngoài componentkhông phải render UI.

Phân loại Side Effects

LoạiVí dụCleanup cần?
DOM manipulationdocument.title, scrollKhông
TimerssetInterval, setTimeout✅ Bắt buộc
Event listenersscroll, resize, keydown✅ Bắt buộc
Network requestsfetch, WebSocket✅ Bắt buộc
SubscriptionsRedux store, rxjs✅ Bắt buộc
Logging/Analyticsconsole, track pageviewThường không
localStorageread/writeKhông

useEffect vs Event Handler — Quy tắc vàng

Câu hỏi: Cái này được trigger bởi USER hay bởi STATE/PROPS?
├── User trigger (click, submit, input) → Event Handler
└── State/Props trigger (auto, after render) → useEffect

Ví dụ phân biệt:

  • document.title cập nhật khi count thay đổi → useEffect (sync với state)
  • API call khi user click Submit → Event handler
  • Analytics khi component mount → useEffect với []
  • Validate form khi user rời field → Event handler (onBlur)

Khi nào KHÔNG cần useEffect

Đây là lỗi phổ biến nhất của beginner:

  • Derived/computed values → Tính trực tiếp, đừng store trong effect
  • Transform data để render → Tính trong render function
  • Handle user events → Event handlers
  • Initialize state → useState với initial value hoặc lazy init
  • Chain state updates → Batch trong event handler

3. 3 Patterns Dependencies

Bảng so sánh

Cú phápChạy khiUse case
useEffect(() => {...})Sau MỖI renderLog all renders, debugging
useEffect(() => {...}, [])1 lần sau mountInitial fetch, setup subscriptions
useEffect(() => {...}, [a, b])Khi a hoặc b thay đổiSync với specific state/props

Mental model: "Subscription List"

Dependencies = danh sách giá trị effect "đăng ký theo dõi". Chỉ khi 1 trong chúng thay đổi, effect mới chạy lại.

Cách React so sánh dependencies

React dùng Object.is() (tương đương ===). Điều này có nghĩa:

  • Primitives (string, number, boolean): So sánh giá trị → Ổn
  • Objects/Arrays: So sánh reference → Tạo mới mỗi render → Effect chạy mỗi render!

Nguyên tắc: Dùng primitive values trong deps, tránh objects/arrays trực tiếp.
Thay vì [user] → dùng [user.id]. Thay vì [options] → dùng [options.page, options.limit].

Stale Closure — Vấn đề cốt lõi

Bản chất: Khi effect capture giá trị từ closure tại thời điểm tạo, không phải lúc chạy. Nếu state đã thay đổi nhưng deps không được khai báo đúng, effect đọc giá trị cũ.

Dấu hiệu: Effect dùng biến X nhưng X không có trong deps → ESLint báo warning exhaustive-deps.

Giải pháp theo thứ tự ưu tiên:

  1. Thêm biến vào deps (giải pháp đúng hầu hết trường hợp)
  2. Dùng functional update setState(prev => ...) thay vì đọc state trong effect
  3. Dùng useRef nếu cần đọc mà không muốn re-run effect (Ngày 21)

ESLint exhaustive-deps Rule

Đây là "người bạn tốt nhất" — không được disable tùy tiện.

  • Rule báo warning → Đó là signal có potential bug
  • Nếu thực sự cần disable → Phải comment lý do rõ ràng
  • Codebase nhiều eslint-disable = technical debt = nhiều bug tiềm ẩn

4. Cleanup

Cleanup Function là gì?

Function được return từ bên trong effect. React gọi nó khi:

  1. Component unmount (bị remove khỏi DOM)
  2. Trước khi effect chạy lại (khi deps thay đổi)

Lifecycle hoàn chỉnh với Cleanup

Mount:
  → Effect SETUP chạy

Deps thay đổi:
  → Cleanup CŨ chạy trước
  → Effect SETUP mới chạy

Unmount:
  → Cleanup cuối cùng chạy

Key insight: Cleanup LUÔN chạy TRƯỚC khi effect chạy lại. Old setup được dọn sạch trước khi new setup được tạo.

Checklist những gì cần cleanup

Timers:

  • setIntervalclearInterval(id)
  • setTimeoutclearTimeout(id)

Event Listeners:

  • window.addEventListener(event, fn)window.removeEventListener(event, fn)
  • Phải dùng cùng 1 reference hàm (đặt handler ngoài effect hoặc trong effect rồi dùng lại)

Network requests:

  • AbortController → controller.abort()
  • isCancelled flag → isCancelled = true

Connections:

  • WebSocket → ws.close()
  • Third-party libraries → instance.destroy()

DOM elements:

  • Media (audio/video) → audio.pause(); audio.remove()

Pattern: isCancelled Flag (đơn giản)

Tạo boolean local trong effect. Cleanup đặt nó thành true. Trước mọi setState trong async, kiểm tra cờ này.

Pattern: AbortController (khuyến nghị cho fetch)

AbortController thực sự cancel request ở network level, tiết kiệm bandwidth. Cleanup gọi controller.abort(), trong catch phải kiểm tra err.name === 'AbortError' để không xử lý abort như normal error.

Sự khác biệt:

  • isCancelled flag: Đơn giản, dùng được với mọi async, request vẫn hoàn thành trên network
  • AbortController: Cancel thực sự request, tiết kiệm bandwidth, chỉ dùng được với fetch

Analogy: Check-in / Check-out khách sạn

  • Setup = Check-in: Nhận key, bật đèn, bật điều hòa
  • Effect active = Ở trong phòng
  • Cleanup = Check-out: Trả key, tắt đèn, tắt điều hòa
  • Không cleanup = Đèn vẫn cháy, hóa đơn tăng mãi (memory leak!)

5. Data Fetching

3-State Pattern — Bắt buộc phải có

Mọi async operation đều cần đủ 3 states:

data: null         → null | <data>
loading: true      → boolean
error: null        → null | <error message>

State transitions:

Idle → Loading → Success
Idle → Loading → Error → Loading (retry) → Success
Success → Loading (refetch) → Success

Template chuẩn — Production-ready

Cấu trúc tư duy (không phải code):

  1. Reset loading/error khi bắt đầu fetch
  2. Tạo async function bên trong effect (không phải async effect trực tiếp!)
  3. Check response.ok — fetch chỉ reject khi network fail, không phải khi 404/500
  4. Parse JSON sau khi đã verify response OK
  5. Kiểm tra isCancelled trước mọi setState
  6. Try/catch bao toàn bộ, handle riêng AbortError
  7. Return cleanup function

Quy tắc async trong useEffect

useEffect callback KHÔNG được là async function. Vì effect có thể return cleanup function, nhưng async function luôn return Promise — gây lỗi.

Pattern đúng: Tạo async function bên trong effect, rồi gọi ngay.

Refetch khi deps thay đổi

Đặt parameter (userId, query, page...) vào deps array. Mỗi khi deps thay đổi: cleanup old request → effect chạy lại với giá trị mới → fetch lại.

check response.ok

fetch Promise chỉ reject khi network error (không có internet, DNS fail...). Các HTTP error (404, 500, 403) vẫn resolve! Phải tự check response.ok và throw Error nếu cần catch xử lý như error.


6. Advanced Patterns

Race Condition — Vấn đề

User gõ nhanh "r" → "re" → "rea" → 3 requests cùng bay. Response không đảm bảo về theo thứ tự. Request "r" (chậm hơn) về sau "rea" → overwrite kết quả đúng bằng kết quả cũ.

Giải pháp 1: Ignore Outdated (isLatest flag)

  • Mỗi effect tạo isLatest = true
  • Cleanup đặt isLatest = false
  • Trước setState kiểm tra if (isLatest)
  • Request cũ về thì bị ignore, không update UI

Trade-off: Request vẫn hoàn thành (tốn bandwidth), nhưng đơn giản, dùng được với mọi async.

Giải pháp 2: AbortController (Preferred)

  • Mỗi effect tạo new AbortController()
  • Pass signal vào fetch
  • Cleanup gọi controller.abort()
  • Request cũ bị cancel thực sự ở network level

Trade-off: Chỉ dùng được với fetch, cần handle AbortError riêng, nhưng tiết kiệm bandwidth thật sự.

Dependent Requests (Sequential)

Khi request B phụ thuộc vào kết quả của request A:

Pattern tư duy:

  1. Fetch A trước
  2. Dùng kết quả A để fetch B
  3. Cả chuỗi nằm trong 1 async function trong effect
  4. Deps là giá trị khởi đầu chuỗi (không phải intermediate results)

Quan trọng: Intermediate results (kết quả trung gian) KHÔNG được đưa vào deps — sẽ gây infinite loop.

Parallel Requests

Khi nhiều requests độc lập nhau, không nên await tuần tự:

Pattern tư duy:

  • Dùng Promise.all() để chạy song song → hoàn thành khi TẤT CẢ xong, fail khi 1 cái fail
  • Dùng Promise.allSettled() → hoàn thành khi TẤT CẢ xong, không fail ngay cả khi có cái fail → Preferred cho production vì xử lý từng kết quả riêng

Khi nào dùng cái nào:

  • Promise.all: Tất cả requests quan trọng như nhau, 1 fail thì cả nhóm fail
  • Promise.allSettled: Muốn hiển thị partial results, từng request có thể fail riêng

7. Dạng bài tập

DẠNG 1 — Side Effect trong render function

Nhận dạng: Code như document.title = ..., console.log(), fetch() nằm trực tiếp trong component body (không trong event handler, không trong useEffect)
Vấn đề: Render phải pure. Side effects trong render → unpredictable, chạy quá nhiều lần
Hướng giải: Chuyển vào useEffect với deps phù hợp

DẠNG 2 — Effect chạy quá nhiều lần

Nhận dạng: Effect log liên tục, API được gọi nhiều lần không cần thiết
Nguyên nhân: Không có deps array (chạy sau mỗi render) hoặc object/array trong deps (reference mới mỗi render)
Hướng giải: Thêm deps array, dùng primitive values trong deps thay vì objects

DẠNG 3 — Effect chỉ chạy 1 lần khi mount

Nhận dạng: "Tôi muốn fetch data 1 lần khi component load"
Hướng giải: useEffect(() => {...}, []) — empty deps

DẠNG 4 — Stale Closure trong Effect

Nhận dạng: Effect đọc giá trị cũ, không update khi state thay đổi; thường xuất hiện với setTimeout, setInterval
Nguyên nhân: State được capture tại thời điểm tạo closure, không phải lúc chạy
Hướng giải: Thêm vào deps, hoặc dùng functional update, hoặc useRef

DẠNG 5 — Memory Leak với Timer

Nhận dạng: setInterval/setTimeout trong effect không có cleanup → interval chạy mãi sau khi component unmount
Hướng giải: Return cleanup function với clearInterval/clearTimeout

DẠNG 6 — Memory Leak với Event Listener

Nhận dạng: addEventListener không có removeEventListener → listeners tích lũy sau mỗi re-render hoặc mount
Hướng giải: Return cleanup với removeEventListener, dùng cùng 1 reference hàm

DẠNG 7 — setState sau unmount

Nhận dạng: Warning "Can't perform a React state update on an unmounted component", thường với async operations
Nguyên nhân: Component unmount trong khi fetch đang pending
Hướng giải: isCancelled flag hoặc AbortController trong cleanup

DẠNG 8 — Data Fetching không có Loading/Error state

Nhận dạng: App show blank hoặc crash khi API chậm/lỗi
Hướng giải: 3-state pattern (data, loading, error) + conditional rendering

DẠNG 9 — async trực tiếp trong useEffect

Nhận dạng: useEffect(async () => {...}) → React warning, cleanup không hoạt động đúng
Hướng giải: Tạo async function bên trong rồi gọi ngay

DẠNG 10 — Race Condition trong search/autocomplete

Nhận dạng: Gõ nhanh → kết quả bị "nhảy" lên kết quả cũ
Hướng giải: isLatest flag hoặc AbortController — cancel/ignore outdated requests

DẠNG 11 — Infinite Loop trong Effect

Nhận dạng: "Maximum update depth exceeded", browser freeze
Nguyên nhân phổ biến:

  • setState trong effect không có deps control → setState → re-render → effect → setState...
  • Object/Array trong deps được tạo mới mỗi render → effect luôn "thấy" deps mới
  • Output của effect nằm trong deps (tạo dependency cycle)

Hướng giải:

  • Thêm deps array phù hợp
  • Dùng primitive values trong deps
  • Output của effect KHÔNG BAO GIỜ nằm trong deps

DẠNG 12 — Sequential vs Parallel Requests

Nhận dạng: "Fetch user rồi dùng kết quả để fetch posts" (sequential) vs "Fetch users và posts cùng lúc" (parallel)
Hướng giải: Sequential → await tuần tự trong async function; Parallel → Promise.all hoặc Promise.allSettled


8. Anti-patterns cần tránh

❌ Side effect trực tiếp trong render

Triệu chứng: Fetch chạy mỗi render → infinite loop
Fix: Chuyển vào useEffect

❌ async effect callback

Triệu chứng: ESLint warning, cleanup không hoạt động đúng
Fix: Tạo async function bên trong rồi gọi

❌ Object/Array trong deps mà không memoize

Triệu chứng: Effect chạy mỗi render dù "không gì thay đổi"
Fix: Dùng primitive, hoặc useMemo (Ngày 23), hoặc restructure state

❌ Không cleanup timers/listeners

Triệu chứng: Memory leak, ghost behaviors sau unmount
Fix: Return cleanup function luôn luôn

❌ Không check isCancelled/AbortController

Triệu chứng: setState sau unmount warning, race conditions
Fix: Cleanup flag hoặc AbortController

❌ Không check response.ok

Triệu chứng: 404/500 errors bị bỏ qua, app hiển thị data rỗng thay vì lỗi
Fix: if (!response.ok) throw new Error(...)

❌ eslint-disable exhaustive-deps không có lý do

Triệu chứng: Stale closure bugs ẩn, khó debug
Fix: Thêm deps đúng, hoặc nếu disable thì comment rõ lý do

❌ Output của effect nằm trong deps

Triệu chứng: Infinite loop
Fix: Derived state thay vì effect + setState

❌ Nhiều effects khổng lồ

Triệu chứng: Khó đọc, khó debug, side effects chạy không cần thiết
Fix: Tách theo concern — mỗi effect một mục đích


9. Interview Questions

Junior Level

Q: useEffect chạy khi nào?
A: SAU khi component render và DOM đã update. Không block browser paint. Timing: Render → DOM update → Browser paint → useEffect.

Q: Phân biệt 3 patterns deps?
A: Không có deps = sau mỗi render; [] = 1 lần sau mount; [a,b] = khi a hoặc b thay đổi.

Q: Tại sao không làm side effects trong render?
A: Render phải pure — cùng input thì cùng output. Side effects trong render: không predictable, React có thể gọi render nhiều lần, không control được timing.

Q: Cleanup function là gì, chạy khi nào?
A: Function được return từ effect. Chạy trước khi effect re-run (khi deps thay đổi) và khi component unmount. Dùng để clear timers, remove listeners, cancel requests.

Q: Tại sao không dùng async trực tiếp trong useEffect?
A: useEffect callback không thể là async vì async function luôn return Promise, nhưng useEffect chỉ accept cleanup function hoặc undefined.


Mid Level

Q: Stale closure trong useEffect là gì? Fix thế nào?
A: Effect capture snapshot giá trị tại thời điểm tạo, không phải khi chạy. Nếu state thay đổi nhưng không có trong deps, effect vẫn thấy giá trị cũ. Fix: thêm vào deps, dùng functional update, hoặc useRef cho mutable values.

Q: Tại sao object trong deps gây effect chạy liên tục?
A: React so sánh deps bằng Object.is() (===). Mỗi render tạo object literal mới → reference khác → React thấy "đã thay đổi" → effect chạy. Fix: dùng primitive values như user.id thay vì user.

Q: Làm sao fetch data chỉ khi userId thay đổi?
A: Đặt userId vào deps: useEffect(() => { fetch... }, [userId]). Effect re-run khi userId thay đổi, cleanup old request, fetch mới.

Q: Làm sao prevent race condition trong search?
A: Hai cách: (1) isLatest flag — cleanup đặt false, chỉ setState khi isLatest còn true. (2) AbortController — cleanup abort request cũ, fetch nhận signal, catch và ignore AbortError.


Senior Level

Q: Thiết kế data fetching strategy cho production app phức tạp?
A: Phân tích:

  • Có race condition risk không (search, user-triggered refetch)? → AbortController
  • Có dependent requests không? → Sequential trong 1 async function, intermediate results không vào deps
  • Có parallel requests không? → Promise.allSettled để handle partial failures
  • Cần caching không? → Custom hook hoặc React Query (Ngày 38+)
  • Retry logic? → Exponential backoff với attempt counter
  • Loading states granular hay global? → Phụ thuộc UX requirement
  • Document trade-offs giữa simplicity và correctness

Q: Identify và fix memory leak trong codebase?
A: Detection: Chrome DevTools Memory profiler (heap snapshots trước/sau navigation), track listener count với getEventListeners(), monitor instance count. Fix theo thứ tự: timers, listeners, subscriptions, async operations. Pattern: Review mọi effect có resource creation → đảm bảo có cleanup pair.

Q: Khi nào KHÔNG nên dùng useEffect?
A: Khi có thể giải quyết không cần effect:

  • Computed values → Tính trực tiếp
  • Data transform cho render → Trong render function
  • Event-driven side effects → Event handlers
  • Initialize state → useState lazy init
  • Chain state updates → Batch trong event handler
    Rule: "Nếu loại bỏ effect mà app vẫn đúng → đừng dùng effect."

Q: Handle cleanup cho complex async workflows?
A: Nhiều lớp: AbortController cho fetch, isCancelled flags cho promises không cancel được, queue management để clear pending jobs, timeout watchdog để force-cancel hung operations, transaction pattern (commit hoặc rollback) để đảm bảo consistency.


10. War Stories

Story: Infinite Loop Ngày Đầu React

setState trong effect không có deps → setState → re-render → effect → setState... Browser freeze, "Maximum update depth exceeded". Debug mãi mới nhận ra. Lesson: Mọi setState trong effect phải được control bằng deps hoặc điều kiện.


Story: Performance Cascade — 5 Effects Liên Hoàn

Production app re-render 50 lần/giây. 5 effects riêng biệt track different stats, mỗi effect trigger state update → cascade re-renders. Fix: Gộp state vào 1 object, reduce 5 effects xuống 1. Performance boost 10x. Lesson: Measure trước, nhưng đừng tạo quá nhiều effects ngay từ đầu.


Story: 1000 Mousemove Listeners — Invisible Leak

App smooth ban đầu, lag dần sau 2-3 giờ. Chrome DevTools: 1000+ mousemove listeners! Effect add listener mỗi re-render không có cleanup. Fix: return () => removeEventListener. Lesson: Test mount/unmount cycles để detect listener leaks. Luôn cleanup listeners.


Story: Race Condition Trong Chat App

setInterval send "typing..." indicator. Empty deps [] → interval capture username lúc mount → stale closure. Bug: Indicator luôn hiển thị user cũ khi switch chat. Fix: Thêm chatId vào deps → interval được recreate mỗi khi switch. Lesson: Empty deps không phải lúc nào cũng đúng — phải hiểu closure.


Story: Ghost Audio — DOM Element Không Cleanup

Video player: Users nghe audio từ video đã stop. Multiple <audio> elements tạo ra, không cleanup khi video thay đổi. Fix: Cleanup pause và remove audio element. Lesson: DOM elements (đặc biệt media) cần explicit cleanup — browser không tự dọn.


Story: ESLint-Disable Technical Debt

Inherit codebase với 50+ eslint-disable exhaustive-deps. Mỗi effect có subtle bug. Mất 2 tuần refactor, fix đúng deps. Phát hiện 10+ bugs chưa report từ stale closures. Lesson: ESLint rule tồn tại vì lý do — respect it. Disable mà không có lý do = bug được che giấu.


Story: Fetch Không Check response.ok

Form submit → 400 Bad Request từ server (validation error) → app không show lỗi vì fetch không reject → user không biết form bị lỗi, nghĩ đã submit thành công. Lesson: Luôn check response.ok, throw Error cho HTTP errors.


11. Decision Framework nhanh

Effect hay Event Handler?

User làm gì đó → trigger → side effect?
├── Có (click, submit, input) → Event Handler
└── Không (state thay đổi tự động) → useEffect

Chọn deps như thế nào?

Effect dùng giá trị X?
├── X là constant bên ngoài component → Không cần thêm
├── X là state/props/biến trong component → Thêm vào deps
└── X là function định nghĩa trong component → useCallback hoặc move ra ngoài

Cần cleanup không?

Effect tạo resource gì không?
├── Timer (interval/timeout) → ✅ Cleanup bắt buộc
├── Event listener → ✅ Cleanup bắt buộc
├── Network request + setState → ✅ Cleanup bắt buộc
├── WebSocket/subscription → ✅ Cleanup bắt buộc
└── Không tạo resource → Không cần cleanup

isCancelled Flag hay AbortController?

Async operation là fetch?
├── Có → AbortController (cancel thực sự, tiết kiệm bandwidth)
└── Không (custom async, setTimeout promise...) → isCancelled flag

Xử lý nhiều requests?

Requests phụ thuộc nhau?
├── Có (kết quả A cần cho B) → Sequential (await tuần tự)
└── Không → Parallel
    └── Tất cả fail nếu 1 cái fail?
        ├── Có → Promise.all
        └── Không → Promise.allSettled (production preferred)

Cần refetch không?

Khi nào data cần refresh?
├── Chỉ lúc mount → deps = []
├── Khi userId/param thay đổi → deps = [userId]
├── Khi user click Refresh → state `refreshKey` trong deps, increment onClick
└── Theo interval → setInterval trong effect với cleanup

Tổng hợp từ Ngày 16–20: useEffect Introduction, Dependencies, Cleanup, Data Fetching Basic & Advanced

Personal tech knowledge base