Skip to content

📅 NGÀY 19: Data Fetching - Basics

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

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


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

  • [ ] Hiểu cách sử dụng fetch API trong useEffect
  • [ ] Implement Loading/Error/Success states pattern chuẩn
  • [ ] Xử lý async/await trong effects một cách an toàn
  • [ ] Biết cách retry failed requests
  • [ ] Apply error handling best practices cho data fetching

🤔 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: Cleanup function chạy khi nào?

    • Đáp án: Trước effect re-run và khi unmount (đã học Ngày 18)
  2. Câu 2: Nếu component unmount trong khi fetch đang pending, điều gì xảy ra?

    • Đáp án: setState sau unmount → Warning! (Cần cleanup - Ngày 18)
  3. Câu 3: fetch API return gì? Promise hay data trực tiếp?

    • Đáp án: Promise - cần .then() hoặc await

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

1.1 Vấn Đề Thực Tế

Hầu hết React apps cần fetch data từ API:

jsx
// ❌ NAIVE ATTEMPT: Fetch trong render
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // ❌ WRONG: Fetch trong render function
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => setUser(data));

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

// PROBLEMS:
// 1. Fetch chạy MỖI render → Infinite loop! (setState → re-render → fetch → setState...)
// 2. Không có loading state → UI flash
// 3. Không handle errors
// 4. Không cleanup khi unmount

Vấn đề:

  • Fetch là side effect → Không nên trong render
  • Cần control KHI NÀO fetch
  • Cần handle 3 states: Loading, Success, Error
  • Cần cleanup để avoid memory leaks

1.2 Giải Pháp: fetch trong useEffect

Pattern chuẩn cho data fetching:

jsx
function UserProfile({ userId }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Reset states khi userId thay đổi
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json();
      })
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((error) => {
        setError(error.message);
        setLoading(false);
      });
  }, [userId]); // ← Refetch khi userId thay đổi

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{data?.name}</div>;
}

Tại sao pattern này tốt:

  1. useEffect: Fetch là side effect, đúng chỗ
  2. Dependencies [userId]: Refetch khi cần
  3. 3 states: Loading/Error/Success
  4. Error handling: Catch network & HTTP errors
  5. Conditional render: UI phù hợp với state

1.3 Mental Model: Data Fetching Lifecycle

┌─────────────────────────────────────────────────────────────┐
│              DATA FETCHING LIFECYCLE                         │
└─────────────────────────────────────────────────────────────┘

INITIAL MOUNT:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. Component mounts
   State: { loading: true, data: null, error: null }
   UI: "Loading..."

2. useEffect runs

3. fetch() initiated
   (Network request in flight...)

4. Response received (2 paths)

   ┌─ SUCCESS PATH ─────────────────┐
   │ response.ok = true             │
   │ ↓                              │
   │ Parse JSON                     │
   │ ↓                              │
   │ setData(data)                  │
   │ setLoading(false)              │
   │ State: { loading: false,       │
   │         data: {...},           │
   │         error: null }          │
   │ UI: Display data ✅            │
   └────────────────────────────────┘

   ┌─ ERROR PATH ───────────────────┐
   │ Network error OR               │
   │ response.ok = false            │
   │ ↓                              │
   │ throw Error                    │
   │ ↓                              │
   │ .catch(error)                  │
   │ setError(error.message)        │
   │ setLoading(false)              │
   │ State: { loading: false,       │
   │         data: null,            │
   │         error: "..." }         │
   │ UI: Error message ❌           │
   └────────────────────────────────┘

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

DEPENDENCY CHANGE (e.g., userId: 1 → 2):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. userId changes

2. useEffect re-runs

3. Reset states:
   setLoading(true)
   setError(null)
   State: { loading: true, data: <old data>, error: null }
   UI: "Loading..." (old data cleared optionally)

4. New fetch with new userId

5. Success/Error paths (same as above)

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

STATE TRANSITIONS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

IDLE → LOADING → SUCCESS
IDLE → LOADING → ERROR → LOADING (retry) → SUCCESS
SUCCESS → LOADING (refetch) → SUCCESS

Analogy dễ hiểu:

Data fetching như đặt hàng online:

  1. Loading: "Đang xử lý đơn hàng..."
  2. Success: "Đơn hàng đã giao!" → Hiển thị sản phẩm
  3. Error: "Giao hàng thất bại!" → Hiển thị lỗi

Dependencies như địa chỉ giao hàng:

  • Địa chỉ thay đổi → Đặt hàng mới đến địa chỉ mới

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

❌ Hiểu lầm #1: "fetch return data trực tiếp"

jsx
// ❌ WRONG
useEffect(() => {
  const data = fetch("/api/users"); // data is a Promise, NOT the actual data!
  setData(data); // Setting Promise object!
}, []);

// ✅ CORRECT
useEffect(() => {
  fetch("/api/users")
    .then((res) => res.json()) // Parse response
    .then((data) => setData(data)); // Then set data
}, []);

✅ Đúng: fetch return Promise, cần .then() hoặc await để lấy data.


❌ Hiểu lầm #2: "response.ok luôn true nếu fetch thành công"

jsx
// ❌ WRONG: Không check response.ok
useEffect(() => {
  fetch("/api/users")
    .then((res) => res.json()) // 404/500 vẫn parse JSON!
    .then(setData)
    .catch(setError); // Only catches network errors
}, []);

// ✅ CORRECT: Check response.ok
useEffect(() => {
  fetch("/api/users")
    .then((res) => {
      if (!res.ok) {
        throw new Error(`HTTP ${res.status}`);
      }
      return res.json();
    })
    .then(setData)
    .catch(setError); // Catches network AND HTTP errors
}, []);

✅ Đúng: fetch chỉ reject với network errors. HTTP errors (404, 500) cần check response.ok.


❌ Hiểu lầm #3: "async function trong useEffect trực tiếp OK"

jsx
// ❌ WRONG: async useEffect
useEffect(async () => {
  const res = await fetch("/api/users");
  const data = await res.json();
  setData(data);
}, []); // Error: useEffect callback cannot be async!

// ✅ CORRECT: async function BÊN TRONG effect
useEffect(() => {
  async function fetchData() {
    const res = await fetch("/api/users");
    const data = await res.json();
    setData(data);
  }
  fetchData();
}, []);

✅ Đúng: useEffect callback KHÔNG được async. Tạo async function bên trong rồi gọi.


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

Demo 1: Pattern Cơ Bản - Simple Data Fetch ⭐

jsx
/**
 * Demo: Basic data fetching pattern
 * Concepts: Loading/Error/Success states, fetch trong useEffect
 */

import { useState, useEffect } from "react";

// Mock API endpoint
const USERS_API = "https://jsonplaceholder.typicode.com/users";

function UserList() {
  // 3 states pattern
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    console.log("🔄 Fetching users...");

    // Method 1: .then() chain
    fetch(USERS_API)
      .then((response) => {
        console.log("📡 Response received:", response.status);

        // Check if response OK
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        return response.json();
      })
      .then((data) => {
        console.log("✅ Data parsed:", data.length, "users");
        setUsers(data);
        setLoading(false);
      })
      .catch((error) => {
        console.error("❌ Fetch error:", error.message);
        setError(error.message);
        setLoading(false);
      });
  }, []); // Empty deps → Fetch once on mount

  // Conditional rendering based on state
  if (loading) {
    return (
      <div style={{ textAlign: "center", padding: "20px" }}>
        <div className="spinner">🔄 Loading users...</div>
      </div>
    );
  }

  if (error) {
    return (
      <div
        style={{
          color: "red",
          padding: "20px",
          border: "1px solid red",
          borderRadius: "4px",
          background: "#fff0f0",
        }}
      >
        <strong>Error:</strong> {error}
      </div>
    );
  }

  return (
    <div>
      <h2>User List ({users.length} users)</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <strong>{user.name}</strong> - {user.email}
          </li>
        ))}
      </ul>

      <div
        style={{ marginTop: "20px", padding: "10px", background: "#f0f0f0" }}
      >
        <h3>📊 State Timeline:</h3>
        <ol>
          <li>Initial: loading=true, data=[], error=null → "Loading..."</li>
          <li>Fetch initiated → Network request in flight</li>
          <li>Success: loading=false, data=[...], error=null → Display list</li>
          <li>OR Error: loading=false, data=[], error="..." → Display error</li>
        </ol>
      </div>
    </div>
  );
}

export default UserList;

Demo 2: Kịch Bản Thực Tế - User Detail Fetch ⭐⭐

jsx
/**
 * Demo: Fetch với dependencies - refetch khi ID thay đổi
 * Use case: User profile page với dynamic userId
 */

import { useState, useEffect } from "react";

const USERS_API = "https://jsonplaceholder.typicode.com/users";

function UserDetail({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    console.log(`🔄 Fetching user ${userId}...`);

    // Reset states khi userId thay đổi
    setLoading(true);
    setError(null);
    // Optional: Clear old data để không flash old user
    // setUser(null);

    // Method 2: async/await (cleaner!)
    async function fetchUser() {
      try {
        const response = await fetch(`${USERS_API}/${userId}`);

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

        const data = await response.json();
        console.log("✅ User loaded:", data.name);

        setUser(data);
        setLoading(false);
      } catch (err) {
        console.error("❌ Fetch error:", err.message);
        setError(err.message);
        setLoading(false);
      }
    }

    fetchUser();
  }, [userId]); // ← Refetch khi userId thay đổi

  if (loading) {
    return <div>Loading user {userId}...</div>;
  }

  if (error) {
    return <div style={{ color: "red" }}>Error: {error}</div>;
  }

  return (
    <div
      style={{ padding: "20px", border: "1px solid #ddd", borderRadius: "8px" }}
    >
      <h2>{user.name}</h2>
      <p>
        <strong>Email:</strong> {user.email}
      </p>
      <p>
        <strong>Phone:</strong> {user.phone}
      </p>
      <p>
        <strong>Website:</strong> {user.website}
      </p>

      <div
        style={{ marginTop: "20px", padding: "10px", background: "#f9f9f9" }}
      >
        <strong>Address:</strong>
        <p>
          {user.address.street}, {user.address.city}
        </p>
      </div>
    </div>
  );
}

// Demo wrapper with user selection
function UserDetailDemo() {
  const [selectedUserId, setSelectedUserId] = useState(1);

  return (
    <div>
      <h2>User Detail Demo</h2>

      <div style={{ marginBottom: "20px" }}>
        <label>Select User: </label>
        <select
          value={selectedUserId}
          onChange={(e) => setSelectedUserId(Number(e.target.value))}
        >
          {[1, 2, 3, 4, 5].map((id) => (
            <option key={id} value={id}>
              User {id}
            </option>
          ))}
        </select>
      </div>

      <UserDetail userId={selectedUserId} />

      <div
        style={{ marginTop: "20px", padding: "10px", background: "#f0f0f0" }}
      >
        <h3>🧪 Test Scenario:</h3>
        <ol>
          <li>Mở Console</li>
          <li>Change user → Effect re-runs</li>
          <li>Loading state shown</li>
          <li>New data fetched và displayed</li>
        </ol>

        <h3>🔍 Key Observations:</h3>
        <ul>
          <li>✅ Dependencies [userId] trigger refetch</li>
          <li>✅ States reset on each fetch</li>
          <li>✅ async/await cleaner than .then()</li>
          <li>✅ Error handling với try/catch</li>
        </ul>
      </div>
    </div>
  );
}

export default UserDetailDemo;

Demo 3: Edge Cases - Retry & Manual Refetch ⭐⭐⭐

jsx
/**
 * Demo: Advanced patterns - Retry failed requests, Manual refetch
 * Edge case: Network failures, stale data
 */

import { useState, useEffect } from "react";

const POSTS_API = "https://jsonplaceholder.typicode.com/posts";

function PostsWithRetry() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [retryCount, setRetryCount] = useState(0);
  const [refetchTrigger, setRefetchTrigger] = useState(0);

  useEffect(() => {
    console.log(`🔄 Fetching posts (attempt ${retryCount + 1})...`);

    setLoading(true);
    setError(null);

    async function fetchPosts() {
      try {
        // Simulate random failure (30% chance)
        if (Math.random() < 0.3) {
          throw new Error("Simulated network error");
        }

        const response = await fetch(POSTS_API);

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

        const data = await response.json();
        console.log("✅ Posts loaded:", data.length);

        setPosts(data);
        setLoading(false);
        setRetryCount(0); // Reset retry count on success
      } catch (err) {
        console.error("❌ Fetch failed:", err.message);
        setError(err.message);
        setLoading(false);
      }
    }

    fetchPosts();
  }, [retryCount, refetchTrigger]); // Trigger refetch khi retry hoặc manual refetch

  const handleRetry = () => {
    setRetryCount((prev) => prev + 1);
  };

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

  if (loading) {
    return (
      <div style={{ textAlign: "center", padding: "40px" }}>
        <div style={{ fontSize: "48px" }}>🔄</div>
        <p>Loading posts...</p>
        {retryCount > 0 && (
          <p style={{ color: "#666" }}>Retry attempt {retryCount}</p>
        )}
      </div>
    );
  }

  if (error) {
    return (
      <div
        style={{
          padding: "20px",
          border: "2px solid #f44336",
          borderRadius: "8px",
          background: "#fff0f0",
          textAlign: "center",
        }}
      >
        <h3 style={{ color: "#f44336" }}>❌ Failed to load posts</h3>
        <p>{error}</p>

        <div style={{ marginTop: "20px" }}>
          <button
            onClick={handleRetry}
            style={{
              padding: "10px 20px",
              background: "#4CAF50",
              color: "white",
              border: "none",
              borderRadius: "4px",
              cursor: "pointer",
              fontSize: "16px",
            }}
          >
            🔄 Retry
          </button>
        </div>

        <p style={{ marginTop: "20px", fontSize: "14px", color: "#666" }}>
          Retry count: {retryCount}
        </p>
      </div>
    );
  }

  return (
    <div>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: "20px",
        }}
      >
        <h2>Posts ({posts.length})</h2>

        <button
          onClick={handleRefresh}
          style={{
            padding: "8px 16px",
            background: "#2196F3",
            color: "white",
            border: "none",
            borderRadius: "4px",
            cursor: "pointer",
          }}
        >
          🔄 Refresh
        </button>
      </div>

      <div
        style={{
          display: "grid",
          gap: "10px",
        }}
      >
        {posts.slice(0, 10).map((post) => (
          <div
            key={post.id}
            style={{
              padding: "15px",
              border: "1px solid #ddd",
              borderRadius: "4px",
              background: "white",
            }}
          >
            <h4>{post.title}</h4>
            <p style={{ color: "#666", fontSize: "14px" }}>
              {post.body.substring(0, 100)}...
            </p>
          </div>
        ))}
      </div>

      <div
        style={{
          marginTop: "30px",
          padding: "15px",
          background: "#f0f0f0",
          borderRadius: "4px",
        }}
      >
        <h3>🎯 Features Demonstrated:</h3>
        <ul>
          <li>
            ✅ <strong>Retry mechanism:</strong> Click Retry button on error
          </li>
          <li>
            ✅ <strong>Manual refetch:</strong> Click Refresh button anytime
          </li>
          <li>
            ✅ <strong>Retry counter:</strong> Track retry attempts
          </li>
          <li>
            ✅ <strong>Simulated failures:</strong> 30% chance to test error
            handling
          </li>
        </ul>

        <h3>🔍 Implementation Details:</h3>
        <pre
          style={{
            background: "white",
            padding: "10px",
            borderRadius: "4px",
            overflow: "auto",
          }}
        >
          {`useEffect(() => {
  fetchPosts();
}, [retryCount, refetchTrigger]);
// ↑ Triggers: 
// - retryCount changes → Retry
// - refetchTrigger changes → Manual refresh`}
        </pre>
      </div>
    </div>
  );
}

export default PostsWithRetry;

🔨 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 basic data fetching pattern
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: useRef, custom hooks, libraries
 *
 * Requirements:
 * 1. Fetch danh sách todos từ API
 * 2. Display loading state
 * 3. Display error state nếu fetch fail
 * 4. Display todos list khi success
 * 5. Show completed/incomplete status
 *
 * API: https://jsonplaceholder.typicode.com/todos
 *
 * 💡 Gợi ý:
 * - 3 states: todos, loading, error
 * - useEffect với empty deps []
 * - Conditional rendering
 */

// ❌ Cách SAI (Anti-pattern):
function WrongTodoList() {
  const [todos, setTodos] = useState([]);

  // ❌ Fetch trong render → Infinite loop!
  fetch("https://jsonplaceholder.typicode.com/todos")
    .then((res) => res.json())
    .then((data) => setTodos(data)); // setState → re-render → fetch again!

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

// Tại sao sai?
// - Fetch trong render → Mỗi render tạo request mới
// - setState trigger re-render → Infinite loop
// - Không có loading/error states

// ✅ Cách ĐÚNG (Best practice):
function CorrectTodoList() {
  const [todos, setTodos] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // ✅ Fetch trong effect
    async function fetchTodos() {
      try {
        const response = await fetch(
          "https://jsonplaceholder.typicode.com/todos",
        );

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

        const data = await response.json();
        setTodos(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchTodos();
  }, []); // ✅ Empty deps → Fetch once

  if (loading) return <div>Loading todos...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {todos.slice(0, 10).map((todo) => (
        <li key={todo.id}>
          {todo.completed ? "✅" : "⭕"} {todo.title}
        </li>
      ))}
    </ul>
  );
}

// Tại sao tốt hơn?
// ✅ Fetch trong effect → Controlled timing
// ✅ Empty deps → Fetch once on mount
// ✅ Loading/Error states → Better UX
// ✅ Conditional rendering

// 🎯 NHIỆM VỤ CỦA BẠN:
function TodoList() {
  // TODO: Declare states (todos, loading, error)

  // TODO: useEffect to fetch todos
  // API: https://jsonplaceholder.typicode.com/todos
  // Handle loading, success, error

  // TODO: Conditional rendering
  // - if loading → "Loading..."
  // - if error → Display error
  // - if success → Display todos (show first 20)

  return (
    <div>
      <h2>Todo List</h2>
      {/* TODO: Implement UI */}
    </div>
  );
}

// ✅ Expected behavior:
// 1. On mount → "Loading todos..."
// 2. After fetch → List of 20 todos with completed status
// 3. On error → "Error: [message]"

export default TodoList;
💡 Solution
jsx
/**
 * TodoList - Level 1: Basic data fetching pattern
 * Fetch todos from jsonplaceholder, show loading/error/success states
 * Display first 20 todos with completed status indicator
 */
import { useState, useEffect } from "react";

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchTodos() {
      try {
        const response = await fetch(
          "https://jsonplaceholder.typicode.com/todos",
        );

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

        const data = await response.json();
        setTodos(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchTodos();
  }, []);

  if (loading) {
    return <div>Loading todos...</div>;
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <div>
      <h2>Todo List (first 20)</h2>
      <ul>
        {todos.slice(0, 20).map((todo) => (
          <li key={todo.id}>
            {todo.completed ? "✅" : "⭕"} {todo.title}
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoList;

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

  • Ban đầu: "Loading todos..."
  • Sau ~0.5–1 giây: hiển thị danh sách 20 todo đầu tiên
    Ví dụ:
    • ✅ delectus aut autem
    • ⭕ quis ut nam facilis et officia qui
    • ✅ fugiat veniam minus
    • ⭕ et porro tempora

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

jsx
/**
 * 🎯 Mục tiêu: Fetch với dependencies - refetch khi params thay đổi
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario: Photo gallery với pagination
 * API: https://jsonplaceholder.typicode.com/photos
 *
 * Requirements:
 * - Fetch photos với pagination (_page, _limit)
 * - Previous/Next buttons
 * - Refetch khi page thay đổi
 * - Loading state mỗi lần fetch
 * - Display page số
 *
 * 🤔 IMPLEMENTATION STRATEGY:
 * - State: photos, loading, error, currentPage
 * - useEffect deps: [currentPage]
 * - API: /photos?_page=${page}&_limit=10
 */

function PhotoGallery() {
  const [photos, setPhotos] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [currentPage, setCurrentPage] = useState(1);

  const PHOTOS_PER_PAGE = 10;

  // TODO: useEffect to fetch photos
  useEffect(() => {
    async function fetchPhotos() {
      try {
        // TODO:
        // 1. Set loading = true, error = null
        // 2. Fetch from API with currentPage
        // 3. Check response.ok
        // 4. Parse JSON
        // 5. Set photos, loading = false

        setLoading(true);
        setError(null);

        const response = await fetch(
          `https://jsonplaceholder.typicode.com/photos?_page=${currentPage}&_limit=${PHOTOS_PER_PAGE}`,
        );

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

        const data = await response.json();
        setPhotos(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchPhotos();
  }, [currentPage]); // ← Refetch khi page thay đổi

  const handlePrevious = () => {
    setCurrentPage((prev) => Math.max(1, prev - 1));
  };

  const handleNext = () => {
    setCurrentPage((prev) => prev + 1);
  };

  if (loading) {
    return (
      <div style={{ textAlign: "center", padding: "40px" }}>
        <div style={{ fontSize: "48px" }}>📸</div>
        <p>Loading photos...</p>
      </div>
    );
  }

  if (error) {
    return <div style={{ color: "red" }}>Error: {error}</div>;
  }

  return (
    <div>
      <h2>Photo Gallery - Page {currentPage}</h2>

      {/* Pagination Controls */}
      <div
        style={{
          marginBottom: "20px",
          display: "flex",
          gap: "10px",
          alignItems: "center",
        }}
      >
        <button
          onClick={handlePrevious}
          disabled={currentPage === 1}
          style={{
            padding: "8px 16px",
            background: currentPage === 1 ? "#ccc" : "#4CAF50",
            color: "white",
            border: "none",
            borderRadius: "4px",
            cursor: currentPage === 1 ? "not-allowed" : "pointer",
          }}
        >
          ← Previous
        </button>

        <span>Page {currentPage}</span>

        <button
          onClick={handleNext}
          style={{
            padding: "8px 16px",
            background: "#4CAF50",
            color: "white",
            border: "none",
            borderRadius: "4px",
            cursor: "pointer",
          }}
        >
          Next →
        </button>
      </div>

      {/* Photo Grid */}
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
          gap: "10px",
        }}
      >
        {photos.map((photo) => (
          <div
            key={photo.id}
            style={{
              border: "1px solid #ddd",
              borderRadius: "4px",
              overflow: "hidden",
              background: "white",
            }}
          >
            <img
              src={photo.thumbnailUrl}
              alt={photo.title}
              style={{ width: "100%", display: "block" }}
            />
            <div style={{ padding: "10px", fontSize: "12px" }}>
              {photo.title.substring(0, 30)}...
            </div>
          </div>
        ))}
      </div>

      <div
        style={{ marginTop: "30px", padding: "15px", background: "#f0f0f0" }}
      >
        <h3>🔍 Key Learnings:</h3>
        <ul>
          <li>
            ✅ <strong>Dependencies:</strong> [currentPage] triggers refetch
          </li>
          <li>
            ✅ <strong>State reset:</strong> setLoading(true) on each fetch
          </li>
          <li>
            ✅ <strong>Pagination:</strong> _page và _limit query params
          </li>
          <li>
            ✅ <strong>UX:</strong> Disable "Previous" on page 1
          </li>
        </ul>

        <h3>🧪 Test:</h3>
        <ol>
          <li>Mở Console</li>
          <li>Click Next → New fetch triggered</li>
          <li>Loading state shown briefly</li>
          <li>New photos loaded</li>
        </ol>
      </div>
    </div>
  );
}

export default PhotoGallery;
💡 Solution
jsx
/**
 * PhotoGallery - Level 2: Pagination with dependencies
 * Fetch photos from jsonplaceholder with _page and _limit
 * Refetch when currentPage changes
 * Show loading/error states
 * Previous/Next pagination controls
 */
import { useState, useEffect } from "react";

function PhotoGallery() {
  const [photos, setPhotos] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [currentPage, setCurrentPage] = useState(1);

  const PHOTOS_PER_PAGE = 10;

  useEffect(() => {
    async function fetchPhotos() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(
          `https://jsonplaceholder.typicode.com/photos?_page=${currentPage}&_limit=${PHOTOS_PER_PAGE}`,
        );

        if (!response.ok) {
          throw new Error(
            `Failed to fetch photos - Status: ${response.status}`,
          );
        }

        const data = await response.json();
        setPhotos(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchPhotos();
  }, [currentPage]);

  const handlePrevious = () => {
    setCurrentPage((prev) => Math.max(1, prev - 1));
  };

  const handleNext = () => {
    setCurrentPage((prev) => prev + 1);
  };

  if (loading) {
    return <div>Loading photos (page {currentPage})...</div>;
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <div>
      <h2>Photo Gallery - Page {currentPage}</h2>

      <div
        style={{
          marginBottom: "1rem",
          display: "flex",
          gap: "1rem",
          alignItems: "center",
        }}
      >
        <button onClick={handlePrevious} disabled={currentPage === 1}>
          ← Previous
        </button>
        <span>Page {currentPage}</span>
        <button onClick={handleNext}>Next →</button>
      </div>

      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
          gap: "1rem",
        }}
      >
        {photos.map((photo) => (
          <div
            key={photo.id}
            style={{
              border: "1px solid #ddd",
              borderRadius: "4px",
              overflow: "hidden",
            }}
          >
            <img
              src={photo.thumbnailUrl}
              alt={photo.title}
              style={{ width: "100%", height: "auto", display: "block" }}
            />
            <div style={{ padding: "0.5rem", fontSize: "0.875rem" }}>
              {photo.title.substring(0, 40)}
              {photo.title.length > 40 ? "..." : ""}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

export default PhotoGallery;

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

  • Ban đầu: "Loading photos (page 1)..."
  • Sau fetch: hiển thị 10 ảnh thumbnail đầu tiên (page 1) + nút Previous (disabled) và Next
  • Nhấn Next → "Loading photos (page 2)..." → hiển thị 10 ảnh tiếp theo (id 11-20)
  • Nhấn Previous → quay về page 1
  • Nếu mạng lỗi hoặc API trả về status ≠ 2xx → hiển thị "Error: Failed to fetch photos - Status: ..."

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

jsx
/**
 * 🎯 Mục tiêu: Search với debounce + data fetching
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 Product Requirements:
 * User Story: "Là user, tôi muốn search users by name,
 * kết quả update real-time khi tôi typing"
 *
 * ✅ Acceptance Criteria:
 * - [ ] Search input
 * - [ ] Debounce 500ms (không search mỗi keystroke)
 * - [ ] Fetch filtered results từ API
 * - [ ] Loading state during search
 * - [ ] Empty state khi no results
 * - [ ] Error handling
 *
 * 🎨 Technical Constraints:
 * - API: https://jsonplaceholder.typicode.com/users
 * - Client-side filtering (API không support search)
 * - Debounce với useState + useEffect
 *
 * 🚨 Edge Cases:
 * - Query empty → Show all users
 * - Query < 2 chars → Don't search
 * - Clear query → Reset results
 *
 * 📝 Implementation Checklist:
 * - [ ] State: searchQuery, debouncedQuery, users, loading, error
 * - [ ] Effect 1: Debounce searchQuery → debouncedQuery
 * - [ ] Effect 2: Fetch users khi debouncedQuery thay đổi
 * - [ ] Client-side filter by name
 */

import { useState, useEffect } from "react";

const USERS_API = "https://jsonplaceholder.typicode.com/users";

function UserSearch() {
  // Search states
  const [searchQuery, setSearchQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");

  // Data states
  const [allUsers, setAllUsers] = useState([]);
  const [filteredUsers, setFilteredUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [isSearching, setIsSearching] = useState(false);
  const [error, setError] = useState(null);

  // Effect 1: Fetch all users on mount
  useEffect(() => {
    console.log("📥 Fetching all users...");

    async function fetchUsers() {
      try {
        const response = await fetch(USERS_API);

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

        const data = await response.json();
        console.log("✅ Users loaded:", data.length);

        setAllUsers(data);
        setFilteredUsers(data); // Initially show all
        setLoading(false);
      } catch (err) {
        console.error("❌ Fetch error:", err.message);
        setError(err.message);
        setLoading(false);
      }
    }

    fetchUsers();
  }, []); // Fetch once

  // Effect 2: Debounce search query
  useEffect(() => {
    setIsSearching(true);

    const timerId = setTimeout(() => {
      console.log("🔍 Debounced query:", searchQuery);
      setDebouncedQuery(searchQuery);
      setIsSearching(false);
    }, 500); // 500ms debounce

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

  // Effect 3: Filter users khi debouncedQuery thay đổi
  useEffect(() => {
    if (!debouncedQuery || debouncedQuery.length < 2) {
      // Show all users if query empty or too short
      setFilteredUsers(allUsers);
      return;
    }

    console.log("🔎 Filtering users by:", debouncedQuery);

    const filtered = allUsers.filter(
      (user) =>
        user.name.toLowerCase().includes(debouncedQuery.toLowerCase()) ||
        user.email.toLowerCase().includes(debouncedQuery.toLowerCase()),
    );

    setFilteredUsers(filtered);
  }, [debouncedQuery, allUsers]);

  if (loading) {
    return (
      <div style={{ textAlign: "center", padding: "40px" }}>
        Loading users...
      </div>
    );
  }

  if (error) {
    return <div style={{ color: "red", padding: "20px" }}>Error: {error}</div>;
  }

  return (
    <div style={{ maxWidth: "800px", margin: "0 auto", padding: "20px" }}>
      <h2>User Search</h2>

      {/* Search Input */}
      <div style={{ marginBottom: "20px" }}>
        <input
          type="text"
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
          placeholder="Search by name or email..."
          style={{
            width: "100%",
            padding: "12px",
            fontSize: "16px",
            border: "2px solid #ddd",
            borderRadius: "4px",
            outline: "none",
          }}
        />

        <div
          style={{
            marginTop: "10px",
            fontSize: "14px",
            color: "#666",
            display: "flex",
            justifyContent: "space-between",
          }}
        >
          <span>
            {isSearching
              ? "🔍 Searching..."
              : `Found ${filteredUsers.length} user(s)`}
          </span>
          {searchQuery && searchQuery.length < 2 && (
            <span style={{ color: "#ff9800" }}>
              Type at least 2 characters to search
            </span>
          )}
        </div>
      </div>

      {/* Results */}
      {filteredUsers.length === 0 ? (
        <div
          style={{
            textAlign: "center",
            padding: "40px",
            background: "#f9f9f9",
            borderRadius: "8px",
          }}
        >
          <div style={{ fontSize: "48px" }}>🔍</div>
          <p>No users found matching "{debouncedQuery}"</p>
        </div>
      ) : (
        <div style={{ display: "grid", gap: "10px" }}>
          {filteredUsers.map((user) => (
            <div
              key={user.id}
              style={{
                padding: "15px",
                border: "1px solid #ddd",
                borderRadius: "4px",
                background: "white",
              }}
            >
              <h3 style={{ margin: "0 0 5px 0" }}>{user.name}</h3>
              <p style={{ margin: "0", color: "#666", fontSize: "14px" }}>
                📧 {user.email}
              </p>
              <p
                style={{ margin: "5px 0 0 0", color: "#666", fontSize: "14px" }}
              >
                🏢 {user.company.name}
              </p>
            </div>
          ))}
        </div>
      )}

      {/* Debug Info */}
      <div
        style={{
          marginTop: "30px",
          padding: "15px",
          background: "#f0f0f0",
          borderRadius: "4px",
          fontSize: "14px",
        }}
      >
        <h3>🔍 Debug Info:</h3>
        <p>
          <strong>Search Query:</strong> "{searchQuery}"
        </p>
        <p>
          <strong>Debounced Query:</strong> "{debouncedQuery}"
        </p>
        <p>
          <strong>Is Searching:</strong> {isSearching ? "Yes" : "No"}
        </p>
        <p>
          <strong>Total Users:</strong> {allUsers.length}
        </p>
        <p>
          <strong>Filtered Users:</strong> {filteredUsers.length}
        </p>
      </div>

      {/* Explanation */}
      <div
        style={{
          marginTop: "20px",
          padding: "15px",
          background: "#e3f2fd",
          borderRadius: "4px",
        }}
      >
        <h3>💡 How It Works:</h3>
        <ol>
          <li>
            <strong>Effect 1:</strong> Fetch all users on mount
          </li>
          <li>
            <strong>Effect 2:</strong> Debounce searchQuery (500ms delay)
          </li>
          <li>
            <strong>Effect 3:</strong> Filter users when debouncedQuery changes
          </li>
        </ol>

        <h3>🎯 Why This Pattern:</h3>
        <ul>
          <li>✅ Debounce prevents excessive filtering</li>
          <li>✅ Client-side filter = instant results</li>
          <li>✅ Separate concerns = easier to maintain</li>
          <li>✅ Loading states = better UX</li>
        </ul>
      </div>
    </div>
  );
}

export default UserSearch;
💡 Solution
jsx
/**
 * UserSearch - Level 3: Search with debounce + client-side filtering
 * - Fetch all users once on mount
 * - Debounce search input (500ms)
 * - Filter users by name or email (client-side)
 * - Show loading / searching / empty / error states
 * - Minimum 2 characters to start filtering
 */
import { useState, useEffect } from "react";

function UserSearch() {
  const [searchQuery, setSearchQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");
  const [allUsers, setAllUsers] = useState([]);
  const [filteredUsers, setFilteredUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [isSearching, setIsSearching] = useState(false);
  const [error, setError] = useState(null);

  // Fetch all users once on mount
  useEffect(() => {
    async function fetchUsers() {
      try {
        const response = await fetch(
          "https://jsonplaceholder.typicode.com/users",
        );
        if (!response.ok) {
          throw new Error(`Failed to fetch users - ${response.status}`);
        }
        const data = await response.json();
        setAllUsers(data);
        setFilteredUsers(data); // initially show everyone
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }
    fetchUsers();
  }, []);

  // Debounce search input
  useEffect(() => {
    setIsSearching(true);
    const timer = setTimeout(() => {
      setDebouncedQuery(searchQuery.trim());
      setIsSearching(false);
    }, 500);

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

  // Filter users when debounced query changes
  useEffect(() => {
    if (!debouncedQuery || debouncedQuery.length < 2) {
      setFilteredUsers(allUsers);
      return;
    }

    const lowerQuery = debouncedQuery.toLowerCase();
    const results = allUsers.filter(
      (user) =>
        user.name.toLowerCase().includes(lowerQuery) ||
        user.email.toLowerCase().includes(lowerQuery),
    );
    setFilteredUsers(results);
  }, [debouncedQuery, allUsers]);

  if (loading) {
    return <div>Loading users...</div>;
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <div>
      <h2>User Search</h2>

      <input
        type="text"
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        placeholder="Search by name or email..."
        style={{ width: "100%", padding: "8px", marginBottom: "12px" }}
      />

      <div style={{ marginBottom: "16px", color: "#555", fontSize: "0.9rem" }}>
        {isSearching ? "Searching..." : `Found ${filteredUsers.length} user(s)`}
        {searchQuery && searchQuery.length < 2 && (
          <span style={{ color: "#e67e22", marginLeft: "12px" }}>
            (Type at least 2 characters to search)
          </span>
        )}
      </div>

      {filteredUsers.length === 0 && debouncedQuery.length >= 2 ? (
        <div>No users found matching "{debouncedQuery}"</div>
      ) : (
        <div style={{ display: "grid", gap: "12px" }}>
          {filteredUsers.map((user) => (
            <div
              key={user.id}
              style={{
                padding: "12px",
                border: "1px solid #ddd",
                borderRadius: "6px",
                background: "#fafafa",
              }}
            >
              <strong>{user.name}</strong>
              <div style={{ color: "#555", fontSize: "0.9rem" }}>
                {user.email}
              </div>
              <div
                style={{ color: "#777", fontSize: "0.85rem", marginTop: "4px" }}
              >
                {user.company.name}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

export default UserSearch;

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

  • Ban đầu: Loading users... → hiển thị toàn bộ 10 users
  • Gõ "le" → sau 500ms: "Found 10 user(s)" (vẫn hiện tất cả vì < 2 ký tự thực sự lọc)
  • Gõ "Lea" → sau debounce: chỉ hiện người có "Lea" trong name hoặc email
    Ví dụ:
  • Gõ "xyz" → "No users found matching "xyz""
  • Xóa input → quay lại hiển thị tất cả users

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

jsx
/**
 * 🎯 Mục tiêu: Comments System với Nested Data Fetching
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Research & Design (20 phút)
 *
 * Context:
 * Xây dựng comment system:
 * - Fetch posts
 * - Click post → Fetch comments cho post đó
 * - Nested loading states
 * - Error handling cho từng level
 *
 * APPROACH OPTIONS:
 *
 * APPROACH 1: Single component, multiple states
 * Pros:
 * - Đơn giản, tất cả trong 1 component
 * - Dễ share state
 * Cons:
 * - Component lớn, khó đọc
 * - Many states to manage
 * - Hard to reuse
 *
 * APPROACH 2: Separate components (Post List + Comment List)
 * Pros:
 * - Separation of concerns
 * - Easier to test
 * - Reusable components
 * Cons:
 * - Props drilling
 * - More files
 *
 * APPROACH 3: Compound pattern với shared state
 * Pros:
 * - Best of both worlds
 * - Clear responsibilities
 * - Maintainable
 * Cons:
 * - Slightly more complex
 *
 * 💭 RECOMMENDATION: Approach 2 (Separate Components)
 *
 * ADR:
 * ---
 * # ADR: Comment System Architecture
 *
 * ## Decision
 * Use separate PostList and CommentList components
 *
 * ## Rationale
 * - Each component handles its own data fetching
 * - Clear separation: posts vs comments
 * - Easier to add features (reply, edit, etc.)
 * - Better testability
 * ---
 */

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

import { useState, useEffect } from "react";

const POSTS_API = "https://jsonplaceholder.typicode.com/posts";
const COMMENTS_API = "https://jsonplaceholder.typicode.com/comments";

// Component 1: Post List
function PostList({ onSelectPost }) {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchPosts() {
      try {
        const response = await fetch(`${POSTS_API}?_limit=10`);

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

        const data = await response.json();
        setPosts(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchPosts();
  }, []);

  if (loading) return <div>Loading posts...</div>;
  if (error) return <div style={{ color: "red" }}>Error: {error}</div>;

  return (
    <div>
      <h3>Posts</h3>
      <div style={{ display: "grid", gap: "10px" }}>
        {posts.map((post) => (
          <div
            key={post.id}
            onClick={() => onSelectPost(post.id)}
            style={{
              padding: "15px",
              border: "2px solid #ddd",
              borderRadius: "4px",
              cursor: "pointer",
              background: "white",
              transition: "all 0.2s",
            }}
            onMouseEnter={(e) => {
              e.currentTarget.style.borderColor = "#4CAF50";
              e.currentTarget.style.background = "#f9f9f9";
            }}
            onMouseLeave={(e) => {
              e.currentTarget.style.borderColor = "#ddd";
              e.currentTarget.style.background = "white";
            }}
          >
            <h4 style={{ margin: "0 0 10px 0" }}>{post.title}</h4>
            <p style={{ margin: 0, color: "#666", fontSize: "14px" }}>
              {post.body.substring(0, 100)}...
            </p>
            <p
              style={{ marginTop: "10px", color: "#4CAF50", fontSize: "12px" }}
            >
              Click to view comments →
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

// Component 2: Comment List
function CommentList({ postId }) {
  const [comments, setComments] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!postId) return;

    console.log(`📥 Fetching comments for post ${postId}...`);

    setLoading(true);
    setError(null);

    async function fetchComments() {
      try {
        const response = await fetch(`${COMMENTS_API}?postId=${postId}`);

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

        const data = await response.json();
        console.log(`✅ Loaded ${data.length} comments`);
        setComments(data);
        setLoading(false);
      } catch (err) {
        console.error("❌ Fetch error:", err.message);
        setError(err.message);
        setLoading(false);
      }
    }

    fetchComments();
  }, [postId]); // Refetch khi postId thay đổi

  if (!postId) {
    return (
      <div
        style={{
          textAlign: "center",
          padding: "40px",
          background: "#f9f9f9",
          borderRadius: "8px",
        }}
      >
        <div style={{ fontSize: "48px" }}>💬</div>
        <p>Select a post to view comments</p>
      </div>
    );
  }

  if (loading) {
    return (
      <div style={{ textAlign: "center", padding: "40px" }}>
        <div style={{ fontSize: "32px" }}>💬</div>
        <p>Loading comments...</p>
      </div>
    );
  }

  if (error) {
    return <div style={{ color: "red" }}>Error: {error}</div>;
  }

  return (
    <div>
      <h3>Comments ({comments.length})</h3>
      <div style={{ display: "grid", gap: "10px" }}>
        {comments.map((comment) => (
          <div
            key={comment.id}
            style={{
              padding: "15px",
              border: "1px solid #ddd",
              borderRadius: "4px",
              background: "white",
            }}
          >
            <div
              style={{
                display: "flex",
                justifyContent: "space-between",
                marginBottom: "10px",
              }}
            >
              <strong style={{ color: "#2196F3" }}>{comment.name}</strong>
              <span style={{ fontSize: "12px", color: "#999" }}>
                {comment.email}
              </span>
            </div>
            <p style={{ margin: 0, color: "#666", fontSize: "14px" }}>
              {comment.body}
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

// Parent Component
function CommentSystem() {
  const [selectedPostId, setSelectedPostId] = useState(null);

  return (
    <div style={{ maxWidth: "1200px", margin: "0 auto", padding: "20px" }}>
      <h2>Comment System</h2>

      <div
        style={{
          display: "grid",
          gridTemplateColumns: "1fr 1fr",
          gap: "20px",
          marginTop: "20px",
        }}
      >
        {/* Left: Post List */}
        <div>
          <PostList onSelectPost={setSelectedPostId} />
        </div>

        {/* Right: Comment List */}
        <div>
          <CommentList postId={selectedPostId} />
        </div>
      </div>

      {/* Architecture Explanation */}
      <div
        style={{
          marginTop: "30px",
          padding: "20px",
          background: "#f0f0f0",
          borderRadius: "8px",
        }}
      >
        <h3>🏗️ Architecture Highlights:</h3>

        <h4>Component Separation:</h4>
        <ul>
          <li>
            <strong>PostList:</strong> Fetches & displays posts
          </li>
          <li>
            <strong>CommentList:</strong> Fetches & displays comments for
            selected post
          </li>
          <li>
            <strong>CommentSystem:</strong> Coordinates both, manages
            selectedPostId
          </li>
        </ul>

        <h4>Data Flow:</h4>
        <ol>
          <li>PostList fetches posts on mount</li>
          <li>User clicks post → onSelectPost(postId) called</li>
          <li>selectedPostId state updates</li>
          <li>CommentList receives new postId via props</li>
          <li>CommentList effect re-runs → Fetches comments</li>
        </ol>

        <h4>Benefits:</h4>
        <ul>
          <li>✅ Each component handles its own data fetching</li>
          <li>✅ Independent loading/error states</li>
          <li>✅ Easy to test each component separately</li>
          <li>✅ Reusable CommentList component</li>
        </ul>
      </div>
    </div>
  );
}

export default CommentSystem;

// 🧪 PHASE 3: Testing (10 phút)
// Manual testing checklist:
// - [ ] Posts load on mount
// - [ ] Click post → Comments load
// - [ ] Click different post → New comments load
// - [ ] Loading states show during fetch
// - [ ] Error states display if fetch fails
// - [ ] No selected post → "Select a post" message
💡 Solution
jsx
/**
 * CommentSystem - Level 4: Nested Data Fetching with separate components
 * - PostList: fetches and displays list of posts
 * - CommentList: fetches comments for selected post (lazy loading)
 * - Parent manages selected post ID
 * - Independent loading/error states for each section
 */
import { useState, useEffect } from "react";

const POSTS_API = "https://jsonplaceholder.typicode.com/posts";
const COMMENTS_API = "https://jsonplaceholder.typicode.com/comments";

// Component: List of posts
function PostList({ onSelectPost }) {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchPosts() {
      try {
        const response = await fetch(`${POSTS_API}?_limit=10`);
        if (!response.ok) {
          throw new Error(`Failed to load posts (${response.status})`);
        }
        const data = await response.json();
        setPosts(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }
    fetchPosts();
  }, []);

  if (loading) return <div>Loading posts...</div>;
  if (error) return <div style={{ color: "red" }}>Error: {error}</div>;

  return (
    <div>
      <h3>Posts</h3>
      {posts.map((post) => (
        <div
          key={post.id}
          onClick={() => onSelectPost(post.id)}
          style={{
            padding: "12px",
            marginBottom: "8px",
            border: "1px solid #ddd",
            borderRadius: "6px",
            cursor: "pointer",
            background: "#f9f9f9",
          }}
        >
          <h4 style={{ margin: "0 0 6px 0" }}>{post.title}</h4>
          <p style={{ margin: 0, color: "#666", fontSize: "0.9rem" }}>
            {post.body.substring(0, 80)}...
          </p>
          <small
            style={{ color: "#4CAF50", marginTop: "6px", display: "block" }}
          >
            Click to view comments →
          </small>
        </div>
      ))}
    </div>
  );
}

// Component: Comments for selected post
function CommentList({ postId }) {
  const [comments, setComments] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!postId) return;

    setLoading(true);
    setError(null);

    async function fetchComments() {
      try {
        const response = await fetch(`${COMMENTS_API}?postId=${postId}`);
        if (!response.ok) {
          throw new Error(`Failed to load comments (${response.status})`);
        }
        const data = await response.json();
        setComments(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchComments();
  }, [postId]);

  if (!postId) {
    return (
      <div style={{ padding: "30px", textAlign: "center", color: "#777" }}>
        Select a post to view its comments
      </div>
    );
  }

  if (loading) return <div>Loading comments...</div>;
  if (error) return <div style={{ color: "red" }}>Error: {error}</div>;

  return (
    <div>
      <h3>Comments ({comments.length})</h3>
      {comments.map((comment) => (
        <div
          key={comment.id}
          style={{
            padding: "12px",
            marginBottom: "12px",
            border: "1px solid #eee",
            borderRadius: "6px",
            background: "white",
          }}
        >
          <div
            style={{
              display: "flex",
              justifyContent: "space-between",
              marginBottom: "6px",
            }}
          >
            <strong style={{ color: "#1976d2" }}>{comment.name}</strong>
            <small style={{ color: "#888" }}>{comment.email}</small>
          </div>
          <p style={{ margin: 0, color: "#444" }}>{comment.body}</p>
        </div>
      ))}
    </div>
  );
}

// Main component
function CommentSystem() {
  const [selectedPostId, setSelectedPostId] = useState(null);

  return (
    <div style={{ maxWidth: "1100px", margin: "0 auto", padding: "20px" }}>
      <h2>Comment System (Posts + Comments)</h2>

      <div
        style={{
          display: "grid",
          gridTemplateColumns: "1fr 1fr",
          gap: "24px",
          marginTop: "20px",
        }}
      >
        <PostList onSelectPost={setSelectedPostId} />
        <CommentList postId={selectedPostId} />
      </div>
    </div>
  );
}

export default CommentSystem;

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

  • Ban đầu:

    • Bên trái: 10 bài post đầu tiên (title + excerpt)
    • Bên phải: "Select a post to view its comments"
  • Click vào một post (ví dụ post id 3):

    • Bên phải chuyển sang "Loading comments..." → hiển thị danh sách comments của post đó Ví dụ:
      • id labore ex et dolorem culpa qui (user@example.com)
      • laudantium enim quasi est quidem magnam...
  • Click post khác → CommentList tự động refetch và hiển thị comments mới

  • Mỗi phần có loading/error state riêng biệt

  • Không fetch comments cho đến khi người dùng chọn post


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

jsx
/**
 * 🎯 Mục tiêu: Dashboard với Multiple Data Sources
 * ⏱️ Thời gian: 90 phút
 *
 * 📋 Feature Specification:
 * Xây dựng analytics dashboard fetch từ nhiều endpoints:
 * 1. User stats
 * 2. Post stats
 * 3. Comment stats
 * 4. Todo completion stats
 * 5. Album/Photo stats
 *
 * 🏗️ Technical Design:
 *
 * 1. Component Architecture:
 *    - Dashboard (parent)
 *    - StatCard (reusable display)
 *    - Each stat fetched independently
 *
 * 2. State Strategy:
 *    - Individual loading/error states per stat
 *    - Global "allLoaded" state
 *    - Refresh mechanism
 *
 * 3. Data Fetching:
 *    - Parallel fetches (not sequential!)
 *    - Error handling per endpoint
 *    - Retry failed fetches
 *
 * 4. Performance:
 *    - Show partial data as it loads
 *    - Don't block entire UI on one failure
 *    - Cache data (localStorage)
 *
 * 5. UX:
 *    - Skeleton loaders
 *    - Progressive enhancement
 *    - Refresh button
 *    - Last updated timestamp
 *
 * ✅ Production Checklist:
 * - [ ] Parallel data fetching
 * - [ ] Individual error handling
 * - [ ] Loading states per stat
 * - [ ] Retry mechanism
 * - [ ] Refresh all data
 * - [ ] localStorage caching
 * - [ ] Timestamp display
 * - [ ] Responsive grid
 * - [ ] Error recovery
 */

import { useState, useEffect } from "react";

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

// Reusable StatCard component
function StatCard({ title, value, icon, loading, error, onRetry }) {
  if (loading) {
    return (
      <div
        style={{
          padding: "20px",
          background: "#f5f5f5",
          borderRadius: "8px",
          textAlign: "center",
        }}
      >
        <div style={{ fontSize: "32px", marginBottom: "10px" }}>⏳</div>
        <div>Loading...</div>
      </div>
    );
  }

  if (error) {
    return (
      <div
        style={{
          padding: "20px",
          background: "#fff0f0",
          border: "2px solid #f44336",
          borderRadius: "8px",
          textAlign: "center",
        }}
      >
        <div style={{ fontSize: "32px", marginBottom: "10px" }}>❌</div>
        <div style={{ color: "#f44336", marginBottom: "10px" }}>{error}</div>
        <button
          onClick={onRetry}
          style={{
            padding: "5px 15px",
            background: "#4CAF50",
            color: "white",
            border: "none",
            borderRadius: "4px",
            cursor: "pointer",
          }}
        >
          Retry
        </button>
      </div>
    );
  }

  return (
    <div
      style={{
        padding: "20px",
        background: "white",
        border: "2px solid #4CAF50",
        borderRadius: "8px",
        textAlign: "center",
      }}
    >
      <div style={{ fontSize: "32px", marginBottom: "10px" }}>{icon}</div>
      <div style={{ fontSize: "12px", color: "#666", marginBottom: "5px" }}>
        {title}
      </div>
      <div style={{ fontSize: "32px", fontWeight: "bold", color: "#4CAF50" }}>
        {value}
      </div>
    </div>
  );
}

function AnalyticsDashboard() {
  // Stats data
  const [stats, setStats] = useState({
    users: { value: null, loading: true, error: null },
    posts: { value: null, loading: true, error: null },
    comments: { value: null, loading: true, error: null },
    todos: { value: null, loading: true, error: null },
    albums: { value: null, loading: true, error: null },
  });

  // Global state
  const [lastUpdated, setLastUpdated] = useState(null);
  const [refreshTrigger, setRefreshTrigger] = useState(0);

  // Generic fetch function
  const fetchStat = async (endpoint, statKey) => {
    try {
      console.log(`📥 Fetching ${statKey}...`);

      // Update loading state
      setStats((prev) => ({
        ...prev,
        [statKey]: { ...prev[statKey], loading: true, error: null },
      }));

      const response = await fetch(`${API_BASE}/${endpoint}`);

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

      const data = await response.json();
      const value = Array.isArray(data) ? data.length : data;

      console.log(`✅ ${statKey}: ${value}`);

      // Update success state
      setStats((prev) => ({
        ...prev,
        [statKey]: { value, loading: false, error: null },
      }));

      // Cache to localStorage
      try {
        localStorage.setItem(
          `stat_${statKey}`,
          JSON.stringify({ value, timestamp: Date.now() }),
        );
      } catch (err) {
        console.warn("localStorage error:", err);
      }
    } catch (err) {
      console.error(`❌ ${statKey} error:`, err.message);

      // Update error state
      setStats((prev) => ({
        ...prev,
        [statKey]: { value: null, loading: false, error: err.message },
      }));
    }
  };

  // Effect: Fetch all stats
  useEffect(() => {
    console.log("🔄 Fetching all stats...");

    // Load cached data first
    Object.keys(stats).forEach((key) => {
      try {
        const cached = localStorage.getItem(`stat_${key}`);
        if (cached) {
          const { value, timestamp } = JSON.parse(cached);
          const age = Date.now() - timestamp;

          // Use cache if < 5 minutes old
          if (age < 5 * 60 * 1000) {
            setStats((prev) => ({
              ...prev,
              [key]: { value, loading: false, error: null },
            }));
          }
        }
      } catch (err) {
        console.warn("Cache load error:", err);
      }
    });

    // Fetch all in parallel
    Promise.all([
      fetchStat("users", "users"),
      fetchStat("posts", "posts"),
      fetchStat("comments", "comments"),
      fetchStat("todos", "todos"),
      fetchStat("albums", "albums"),
    ]).then(() => {
      setLastUpdated(new Date());
      console.log("✅ All stats loaded");
    });
  }, [refreshTrigger]); // Refetch when refreshTrigger changes

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

  const handleRetry = (statKey, endpoint) => {
    fetchStat(endpoint, statKey);
  };

  // Check if all loaded
  const allLoaded = Object.values(stats).every((stat) => !stat.loading);
  const anyError = Object.values(stats).some((stat) => stat.error);

  return (
    <div style={{ maxWidth: "1200px", margin: "0 auto", padding: "20px" }}>
      {/* Header */}
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: "30px",
        }}
      >
        <div>
          <h2 style={{ margin: 0 }}>Analytics Dashboard</h2>
          {lastUpdated && (
            <p style={{ margin: "5px 0 0 0", color: "#666", fontSize: "14px" }}>
              Last updated: {lastUpdated.toLocaleTimeString()}
            </p>
          )}
        </div>

        <button
          onClick={handleRefresh}
          style={{
            padding: "10px 20px",
            background: "#4CAF50",
            color: "white",
            border: "none",
            borderRadius: "4px",
            cursor: "pointer",
            fontSize: "16px",
          }}
        >
          🔄 Refresh All
        </button>
      </div>

      {/* Status Bar */}
      <div
        style={{
          padding: "15px",
          background: allLoaded
            ? anyError
              ? "#fff3cd"
              : "#d4edda"
            : "#cfe2ff",
          border: `2px solid ${allLoaded ? (anyError ? "#ffc107" : "#4CAF50") : "#2196F3"}`,
          borderRadius: "4px",
          marginBottom: "20px",
          textAlign: "center",
        }}
      >
        {!allLoaded && "⏳ Loading stats..."}
        {allLoaded && !anyError && "✅ All stats loaded successfully"}
        {allLoaded && anyError && "⚠️ Some stats failed to load"}
      </div>

      {/* Stats Grid */}
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
          gap: "20px",
          marginBottom: "30px",
        }}
      >
        <StatCard
          title="Total Users"
          value={stats.users.value}
          icon="👥"
          loading={stats.users.loading}
          error={stats.users.error}
          onRetry={() => handleRetry("users", "users")}
        />

        <StatCard
          title="Total Posts"
          value={stats.posts.value}
          icon="📝"
          loading={stats.posts.loading}
          error={stats.posts.error}
          onRetry={() => handleRetry("posts", "posts")}
        />

        <StatCard
          title="Total Comments"
          value={stats.comments.value}
          icon="💬"
          loading={stats.comments.loading}
          error={stats.comments.error}
          onRetry={() => handleRetry("comments", "comments")}
        />

        <StatCard
          title="Total Todos"
          value={stats.todos.value}
          icon="✓"
          loading={stats.todos.loading}
          error={stats.todos.error}
          onRetry={() => handleRetry("todos", "todos")}
        />

        <StatCard
          title="Total Albums"
          value={stats.albums.value}
          icon="📸"
          loading={stats.albums.loading}
          error={stats.albums.error}
          onRetry={() => handleRetry("albums", "albums")}
        />
      </div>

      {/* Technical Details */}
      <div
        style={{
          padding: "20px",
          background: "#f0f0f0",
          borderRadius: "8px",
        }}
      >
        <h3>🏗️ Technical Implementation:</h3>

        <h4>Parallel Fetching:</h4>
        <pre
          style={{
            background: "white",
            padding: "10px",
            borderRadius: "4px",
            overflow: "auto",
          }}
        >
          {`Promise.all([
  fetchStat('users', 'users'),
  fetchStat('posts', 'posts'),
  fetchStat('comments', 'comments'),
  fetchStat('todos', 'todos'),
  fetchStat('albums', 'albums'),
]);

// All requests fire simultaneously!
// Don't wait for one to finish before starting next`}
        </pre>

        <h4>Key Features:</h4>
        <ul>
          <li>
            ✅ <strong>Parallel fetching:</strong> All endpoints called at once
          </li>
          <li>
            ✅ <strong>Individual states:</strong> Each stat has own
            loading/error
          </li>
          <li>
            ✅ <strong>Progressive display:</strong> Show data as it arrives
          </li>
          <li>
            ✅ <strong>Error recovery:</strong> Retry button per stat
          </li>
          <li>
            ✅ <strong>localStorage cache:</strong> Fast initial load
          </li>
          <li>
            ✅ <strong>Refresh mechanism:</strong> Refetch all data
          </li>
        </ul>

        <h4>State Structure:</h4>
        <pre
          style={{
            background: "white",
            padding: "10px",
            borderRadius: "4px",
            overflow: "auto",
          }}
        >
          {`stats = {
  users: { value: 10, loading: false, error: null },
  posts: { value: 100, loading: false, error: null },
  comments: { value: 500, loading: true, error: null },
  // ... etc
}`}
        </pre>
      </div>
    </div>
  );
}

export default AnalyticsDashboard;

// 📋 TESTING CHECKLIST:
// - [ ] All stats load on mount
// - [ ] Loading states show while fetching
// - [ ] Stats display when loaded
// - [ ] Error states show for failed fetches
// - [ ] Retry button refetches failed stat
// - [ ] Refresh All button refetches everything
// - [ ] localStorage caching works (check DevTools)
// - [ ] Timestamp updates after refresh
// - [ ] Status bar shows correct state
// - [ ] Partial failures handled gracefully
💡 Solution
jsx
/**
 * AnalyticsDashboard - Level 5: Production-ready dashboard with multiple parallel data sources
 * - Parallel fetching from 5 different endpoints
 * - Individual loading/error/retry states per statistic
 * - Global refresh button + per-stat retry
 * - Basic localStorage caching (5-minute TTL)
 * - Last updated timestamp
 * - Progressive loading (show data as soon as available)
 */
import { useState, useEffect } from "react";

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

function StatCard({ title, value, icon, loading, error, onRetry }) {
  if (loading) {
    return (
      <div
        style={{
          padding: "20px",
          background: "#f5f5f5",
          borderRadius: "8px",
          textAlign: "center",
        }}
      >
        Loading {title}...
      </div>
    );
  }

  if (error) {
    return (
      <div
        style={{
          padding: "20px",
          background: "#fff0f0",
          border: "1px solid #f44336",
          borderRadius: "8px",
          textAlign: "center",
        }}
      >
        <div style={{ color: "#f44336", marginBottom: "8px" }}>
          Error loading {title}
        </div>
        <div style={{ fontSize: "0.9rem", marginBottom: "12px" }}>{error}</div>
        <button
          onClick={onRetry}
          style={{
            padding: "6px 16px",
            background: "#4CAF50",
            color: "white",
            border: "none",
            borderRadius: "4px",
            cursor: "pointer",
          }}
        >
          Retry
        </button>
      </div>
    );
  }

  return (
    <div
      style={{
        padding: "20px",
        background: "white",
        border: "1px solid #4CAF50",
        borderRadius: "8px",
        textAlign: "center",
      }}
    >
      <div style={{ fontSize: "2.5rem", marginBottom: "8px" }}>{icon}</div>
      <div style={{ fontSize: "0.9rem", color: "#666", marginBottom: "4px" }}>
        {title}
      </div>
      <div style={{ fontSize: "2.2rem", fontWeight: "bold", color: "#4CAF50" }}>
        {value !== null ? value.toLocaleString() : "—"}
      </div>
    </div>
  );
}

function AnalyticsDashboard() {
  const [stats, setStats] = useState({
    users: { value: null, loading: true, error: null },
    posts: { value: null, loading: true, error: null },
    comments: { value: null, loading: true, error: null },
    todos: { value: null, loading: true, error: null },
    albums: { value: null, loading: true, error: null },
  });

  const [lastUpdated, setLastUpdated] = useState(null);
  const [refreshTrigger, setRefreshTrigger] = useState(0);

  // Helper to fetch one statistic
  const fetchStat = async (endpoint, key) => {
    try {
      // Update state to loading
      setStats((prev) => ({
        ...prev,
        [key]: { ...prev[key], loading: true, error: null },
      }));

      const response = await fetch(`${API_BASE}/${endpoint}`);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();
      const count = Array.isArray(data) ? data.length : 0;

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

      // Cache
      try {
        localStorage.setItem(
          `stat_${key}`,
          JSON.stringify({ value: count, timestamp: Date.now() }),
        );
      } catch (e) {
        // silent fail
      }
    } catch (err) {
      setStats((prev) => ({
        ...prev,
        [key]: { value: null, loading: false, error: err.message },
      }));
    }
  };

  // Load from cache + fetch fresh data
  useEffect(() => {
    // Try to load from cache first (fast initial render)
    const keys = ["users", "posts", "comments", "todos", "albums"];
    keys.forEach((key) => {
      try {
        const cached = localStorage.getItem(`stat_${key}`);
        if (cached) {
          const { value, timestamp } = JSON.parse(cached);
          if (Date.now() - timestamp < 5 * 60 * 1000) {
            // 5 minutes
            setStats((prev) => ({
              ...prev,
              [key]: { value, loading: false, error: null },
            }));
          }
        }
      } catch (e) {
        // ignore cache errors
      }
    });

    // Then fetch fresh data in parallel
    const fetches = [
      fetchStat("users", "users"),
      fetchStat("posts", "posts"),
      fetchStat("comments", "comments"),
      fetchStat("todos", "todos"),
      fetchStat("albums", "albums"),
    ];

    Promise.all(fetches).then(() => {
      setLastUpdated(new Date());
    });
  }, [refreshTrigger]);

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

  const handleRetry = (key, endpoint) => {
    fetchStat(endpoint, key);
  };

  const allLoaded = Object.values(stats).every((s) => !s.loading);
  const hasError = Object.values(stats).some((s) => s.error !== null);

  return (
    <div style={{ maxWidth: "1200px", margin: "0 auto", padding: "20px" }}>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: "24px",
        }}
      >
        <div>
          <h2>Analytics Dashboard</h2>
          {lastUpdated && (
            <p
              style={{ color: "#666", fontSize: "0.9rem", margin: "4px 0 0 0" }}
            >
              Last updated: {lastUpdated.toLocaleTimeString()}
            </p>
          )}
        </div>
        <button
          onClick={handleRefresh}
          style={{
            padding: "10px 20px",
            background: "#2196F3",
            color: "white",
            border: "none",
            borderRadius: "6px",
            cursor: "pointer",
          }}
        >
          Refresh All
        </button>
      </div>

      <div
        style={{
          padding: "12px",
          marginBottom: "24px",
          background: allLoaded
            ? hasError
              ? "#fff3cd"
              : "#e8f5e9"
            : "#e3f2fd",
          border: `1px solid ${allLoaded ? (hasError ? "#ffb74d" : "#81c784") : "#64b5f6"}`,
          borderRadius: "6px",
          textAlign: "center",
          fontWeight: "500",
        }}
      >
        {!allLoaded && "Loading dashboard data..."}
        {allLoaded && !hasError && "All statistics loaded successfully"}
        {allLoaded &&
          hasError &&
          "Some statistics failed to load — check individual cards"}
      </div>

      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
          gap: "20px",
        }}
      >
        <StatCard
          title="Total Users"
          value={stats.users.value}
          icon="👤"
          loading={stats.users.loading}
          error={stats.users.error}
          onRetry={() => handleRetry("users", "users")}
        />
        <StatCard
          title="Total Posts"
          value={stats.posts.value}
          icon="📝"
          loading={stats.posts.loading}
          error={stats.posts.error}
          onRetry={() => handleRetry("posts", "posts")}
        />
        <StatCard
          title="Total Comments"
          value={stats.comments.value}
          icon="💬"
          loading={stats.comments.loading}
          error={stats.comments.error}
          onRetry={() => handleRetry("comments", "comments")}
        />
        <StatCard
          title="Total Todos"
          value={stats.todos.value}
          icon="✓"
          loading={stats.todos.loading}
          error={stats.todos.error}
          onRetry={() => handleRetry("todos", "todos")}
        />
        <StatCard
          title="Total Albums"
          value={stats.albums.value}
          icon="📀"
          loading={stats.albums.loading}
          error={stats.albums.error}
          onRetry={() => handleRetry("albums", "albums")}
        />
      </div>
    </div>
  );
}

export default AnalyticsDashboard;

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

  • Lần đầu mở:

    • Tất cả card hiển thị "Loading ..." rất nhanh (nếu có cache) hoặc bắt đầu fetch
    • Dần dần từng card hoàn thành (do parallel fetch) → số liệu xuất hiện lần lượt
    • Ví dụ: Total Users: 10, Total Posts: 100, Total Comments: 500, Total Todos: 200, Total Albums: 100
  • Nhấn "Refresh All" → tất cả card quay về loading → fetch lại → cập nhật timestamp

  • Nếu một endpoint lỗi (giả sử mạng chập chờn):

    • Chỉ card đó hiển thị lỗi + nút Retry
    • Các card khác vẫn hiển thị bình thường
    • Status bar chuyển sang màu vàng + thông báo "Some statistics failed to load"
  • Sau 5 phút, refresh lại → dùng dữ liệu cache cũ cho đến khi fetch mới hoàn tất


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

Bảng So Sánh: fetch Patterns

PatternCodeProsConsUse Case
.then() chainfetch(url).then(res => res.json()).then(setData)✅ Simple
✅ No async wrapper
❌ Callback hell
❌ Harder error handling
Simple fetches
async/awaitconst res = await fetch(url); const data = await res.json();✅ Readable
✅ try/catch
❌ Need wrapper functionComplex logic
Promise.allPromise.all([fetch(url1), fetch(url2)])✅ Parallel
✅ Fast
❌ All-or-nothingMultiple endpoints

Bảng So Sánh: State Patterns

StatesCodeProsConsWhen to Use
3 separate statesconst [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
✅ Clear
✅ Easy to understand
❌ 3 setStatesStandard approach
Single state objectconst [state, setState] = useState({ data: null, loading: true, error: null });✅ Atomic updates
✅ Fewer setStates
❌ More verbose updatesComplex state
useReducerconst [state, dispatch] = useReducer(reducer, initialState);✅ Predictable
✅ Testable
❌ More boilerplateWill learn Ngày 12

Decision Tree: Khi nào fetch data?

Component cần data từ API?

├─ Data STATIC (không đổi theo props)?
│  → useEffect(() => fetch(), [])
│  → Fetch once on mount
│  → Example: App configuration, static lists

├─ Data depends on PROPS/STATE?
│  → useEffect(() => fetch(), [prop, state])
│  → Refetch khi deps thay đổi
│  → Example: User profile (depends on userId)

├─ Data from USER ACTION (click, submit)?
│  │
│  ├─ Needs to refetch automatically sau action?
│  │  → Event handler + state update + useEffect
│  │  → Example: Delete item → Refetch list
│  │
│  └─ One-time fetch (không cần refetch)?
│     → fetch trong event handler trực tiếp
│     → Example: Submit form → POST data

└─ NHIỀU data sources cùng lúc?

   ├─ Sequential (A depends on B)?
   │  → useEffect chain hoặc nested effects (Ngày 20)

   └─ Parallel (independent)?
      → Promise.all() trong 1 effect
      → Example: Dashboard stats

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

Bug #1: Missing response.ok Check 🚨

jsx
/**
 * 🐛 BUG: 404/500 errors không được catch
 * 🎯 Nhiệm vụ: Add proper error handling
 */

function BuggyUserFetch({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // ❌ BUG: Không check response.ok
    fetch(`/api/users/${userId}`)
      .then((res) => res.json()) // Parse JSON even if 404!
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

  // Render...
}

// 🤔 CÂU HỎI DEBUG:
// 1. userId = 999 (không tồn tại) → Response 404
// 2. .then(res => res.json()) có chạy không?
// 3. data là gì? setUser(data) thế nào?
// 4. .catch() có bắt được error không?

// 💡 GIẢI THÍCH:
// - fetch() chỉ reject với NETWORK errors (no internet, DNS fail, etc.)
// - HTTP errors (404, 500) → fetch resolves successfully!
// - res.json() vẫn chạy, parse error message từ server
// - data = { error: "User not found" } → setUser với error object!
// - UI hiển thị "undefined" hoặc crash

// ✅ FIX: Check response.ok
function Fixed({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUser() {
      try {
        const response = await fetch(`/api/users/${userId}`);

        // ✅ Check response.ok BEFORE parsing
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const data = await response.json();
        setUser(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchUser();
  }, [userId]);

  // Render...
}

// 🎓 BÀI HỌC:
// - LUÔN check response.ok trước khi parse
// - fetch không auto-throw cho HTTP errors
// - HTTP errors cần handle manually

Bug #2: setState After Unmount ⚠️

jsx
/**
 * 🐛 BUG: setState trên unmounted component
 * 🎯 Nhiệm vụ: Add cleanup flag
 */

function BuggySlowFetch({ endpoint }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // ❌ No cleanup!
    async function fetchData() {
      const response = await fetch(endpoint);
      const data = await response.json();

      // Sau 3 giây, nếu component unmount → Warning!
      setTimeout(() => {
        setData(data);
        setLoading(false);
      }, 3000);
    }

    fetchData();
  }, [endpoint]);

  // Render...
}

// 🤔 CÂU HỎI DEBUG:
// 1. Component mount → Fetch starts
// 2. User navigates away sau 1 giây → Component unmounts
// 3. 2 giây sau (total 3s) → setTimeout callback runs
// 4. Gì xảy ra?

// 💡 GIẢI THÍCH:
// - setTimeout callback vẫn chạy sau unmount
// - setData() + setLoading() called on unmounted component
// - React warning: "Can't perform a React state update on an unmounted component"
// - Memory leak potential

// ✅ FIX: Cleanup flag
function Fixed({ endpoint }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let isCancelled = false; // ← Cleanup flag

    async function fetchData() {
      const response = await fetch(endpoint);
      const data = await response.json();

      setTimeout(() => {
        // ✅ Only setState if not cancelled
        if (!isCancelled) {
          setData(data);
          setLoading(false);
        }
      }, 3000);
    }

    fetchData();

    // Cleanup: Set flag
    return () => {
      isCancelled = true;
    };
  }, [endpoint]);

  // Render...
}

// 🎓 BÀI HỌC:
// - Async operations cần cleanup flag
// - Check flag trước mọi setState
// - Prevents warnings và memory leaks

Bug #3: Infinite Fetch Loop 🔁

jsx
/**
 * 🐛 BUG: Fetch trigger infinite re-renders
 * 🎯 Nhiệm vụ: Fix dependencies
 */

function BuggyFilteredList() {
  const [items, setItems] = useState([]);
  const [filter, setFilter] = useState({ category: "all" });

  useEffect(() => {
    // ❌ BUG: filter object trong deps
    async function fetchItems() {
      const response = await fetch(`/api/items?category=${filter.category}`);
      const data = await response.json();
      setItems(data);
    }

    fetchItems();
  }, [filter]); // ← filter is an object!

  const handleFilter = (category) => {
    // ❌ Creates NEW object → filter ref changes → Effect re-runs
    setFilter({ category });
  };

  // Render...
}

// 🤔 CÂU HỎI DEBUG:
// 1. Initial render → filter = { category: 'all' }
// 2. Effect runs → Fetch items
// 3. Click filter "electronics" → setFilter({ category: 'electronics' })
// 4. filter object reference thay đổi?
// 5. Effect re-run → Expected
// 6. Nhưng nếu click "electronics" lần 2?

// 💡 GIẢI THÍCH:
// - setFilter({ category: 'electronics' }) creates NEW object
// - Even same values → Different reference
// - React compares deps with Object.is() (===)
// - { category: 'electronics' } !== { category: 'electronics' }
// - Effect re-runs unnecessarily!
// - If fetching triggers filter update → Infinite loop

// ✅ FIX #1: Use primitive value
function FixedV1() {
  const [items, setItems] = useState([]);
  const [category, setCategory] = useState("all"); // ← Primitive!

  useEffect(() => {
    async function fetchItems() {
      const response = await fetch(`/api/items?category=${category}`);
      const data = await response.json();
      setItems(data);
    }

    fetchItems();
  }, [category]); // ← Primitive comparison

  const handleFilter = (newCategory) => {
    setCategory(newCategory); // String comparison works!
  };

  // Render...
}

// ✅ FIX #2: Extract object property
function FixedV2() {
  const [items, setItems] = useState([]);
  const [filter, setFilter] = useState({ category: "all" });

  useEffect(() => {
    async function fetchItems() {
      const response = await fetch(`/api/items?category=${filter.category}`);
      const data = await response.json();
      setItems(data);
    }

    fetchItems();
  }, [filter.category]); // ← Primitive property!

  // Render...
}

// 🎓 BÀI HỌC:
// - Avoid objects/arrays trong deps
// - Use primitive values (string, number, boolean)
// - Extract object properties nếu cần
// - useMemo (Ngày 28) cho complex objects

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

Knowledge Check

Đánh dấu ✅ những điều bạn đã hiểu:

Concepts:

  • [ ] Tôi hiểu fetch API return Promise
  • [ ] Tôi biết check response.ok cho HTTP errors
  • [ ] Tôi hiểu 3 states pattern (data, loading, error)
  • [ ] Tôi biết khi nào dùng .then() vs async/await
  • [ ] Tôi hiểu dependencies trigger refetch

Practices:

  • [ ] Tôi có thể fetch data trong useEffect
  • [ ] Tôi implement loading/error states properly
  • [ ] Tôi handle HTTP errors correctly
  • [ ] Tôi biết refetch khi props/state thay đổi
  • [ ] Tôi tránh được infinite loops

Debugging:

  • [ ] Tôi nhận biết được missing response.ok check
  • [ ] Tôi biết fix setState after unmount
  • [ ] Tôi hiểu object dependencies gây re-fetch
  • [ ] Tôi có thể debug fetch failures
  • [ ] Tôi trace được data flow

Code Review Checklist

Khi review data fetching code:

Fetch Logic:

  • [ ] fetch trong useEffect (không phải render)
  • [ ] response.ok checked before parsing
  • [ ] try/catch hoặc .catch() error handling
  • [ ] Dependencies array đúng

State Management:

  • [ ] 3 states: data, loading, error
  • [ ] States reset khi refetch
  • [ ] Conditional rendering based on states
  • [ ] No setState after unmount

Error Handling:

  • [ ] Network errors caught
  • [ ] HTTP errors caught (response.ok)
  • [ ] Error messages displayed to user
  • [ ] Retry mechanism (optional)

Performance:

  • [ ] No unnecessary refetches
  • [ ] Primitive dependencies (not objects)
  • [ ] Cleanup for async operations
  • [ ] Loading states prevent multiple clicks

🏠 BÀI TẬP VỀ NHÀ

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

Bài 1: Weather App

jsx
/**
 * Tạo weather app:
 * - Fetch weather data từ API
 * - Display current temperature, conditions
 * - Search by city name
 * - Loading/Error states
 *
 * API: https://api.openweathermap.org/data/2.5/weather?q=London&appid=YOUR_KEY
 * (Hoặc dùng mock data nếu không có API key)
 *
 * Requirements:
 * - Input field cho city name
 * - Fetch khi user submit
 * - Display weather info
 * - Handle errors (city not found)
 */
💡 Solution
jsx
/**
 * WeatherApp - Bài tập về nhà 1: Weather information by city
 * - Input city name → fetch weather data on submit
 * - Display temperature, condition, feels like, humidity, wind
 * - Loading and error states
 * - Simple responsive layout
 *
 * API: Open-Meteo (free, no key needed)
 * Endpoint: https://api.open-meteo.com/v1/forecast?latitude=...&longitude=...&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m
 *
 * Note: Vì jsonplaceholder không có weather, dùng Open-Meteo + geocode qua Nominatim
 */
import { useState } from "react";

function WeatherApp() {
  const [city, setCity] = useState("");
  const [weather, setWeather] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchWeather = async (cityName) => {
    if (!cityName.trim()) return;

    setLoading(true);
    setError(null);
    setWeather(null);

    try {
      // Bước 1: Geocode city → lat/lon (dùng Nominatim - OpenStreetMap)
      const geoRes = await fetch(
        `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cityName)}&format=json&limit=1`,
      );
      if (!geoRes.ok) throw new Error("Không tìm thấy thành phố");
      const geoData = await geoRes.json();

      if (geoData.length === 0) {
        throw new Error(`Không tìm thấy "${cityName}"`);
      }

      const { lat, lon } = geoData[0];

      // Bước 2: Fetch weather từ Open-Meteo
      const weatherRes = await fetch(
        `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto`,
      );
      if (!weatherRes.ok) throw new Error("Lỗi khi lấy dữ liệu thời tiết");
      const weatherData = await weatherRes.json();

      const current = weatherData.current;

      setWeather({
        city: geoData[0].display_name.split(",")[0],
        temperature: current.temperature_2m,
        feelsLike: current.apparent_temperature,
        humidity: current.relative_humidity_2m,
        windSpeed: current.wind_speed_10m,
        condition: getWeatherCondition(current.weather_code),
        time: new Date(current.time).toLocaleTimeString([], {
          hour: "2-digit",
          minute: "2-digit",
        }),
      });
    } catch (err) {
      setError(err.message || "Có lỗi xảy ra. Vui lòng thử lại.");
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    fetchWeather(city);
  };

  // Simple weather code → description (WMO codes)
  const getWeatherCondition = (code) => {
    const conditions = {
      0: "Trời quang đãng",
      1: "Ít mây",
      2: "Mây rải rác",
      3: "Nhiều mây",
      45: "Sương mù",
      51: "Mưa phùn nhẹ",
      61: "Mưa nhẹ",
      63: "Mưa vừa",
      71: "Tuyết nhẹ",
      80: "Mưa rào nhẹ",
      95: "Có sấm sét",
    };
    return conditions[code] || "Không xác định";
  };

  return (
    <div
      style={{
        maxWidth: "500px",
        margin: "40px auto",
        padding: "20px",
        textAlign: "center",
      }}
    >
      <h1>Weather App</h1>

      <form onSubmit={handleSubmit} style={{ marginBottom: "24px" }}>
        <input
          type="text"
          value={city}
          onChange={(e) => setCity(e.target.value)}
          placeholder="Nhập tên thành phố (ví dụ: Hanoi, Saigon, London)"
          style={{
            width: "100%",
            padding: "12px",
            fontSize: "16px",
            borderRadius: "6px",
            border: "1px solid #ccc",
            marginBottom: "12px",
          }}
        />
        <button
          type="submit"
          disabled={loading || !city.trim()}
          style={{
            padding: "12px 24px",
            fontSize: "16px",
            background: loading ? "#ccc" : "#2196F3",
            color: "white",
            border: "none",
            borderRadius: "6px",
            cursor: loading ? "not-allowed" : "pointer",
          }}
        >
          {loading ? "Đang tải..." : "Xem thời tiết"}
        </button>
      </form>

      {error && (
        <div
          style={{
            color: "red",
            margin: "20px 0",
            padding: "12px",
            background: "#ffebee",
            borderRadius: "6px",
          }}
        >
          {error}
        </div>
      )}

      {weather && (
        <div
          style={{
            padding: "24px",
            background: "linear-gradient(135deg, #e3f2fd, #bbdefb)",
            borderRadius: "12px",
            boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
          }}
        >
          <h2>{weather.city}</h2>
          <p
            style={{ fontSize: "0.95rem", color: "#555", marginBottom: "16px" }}
          >
            Cập nhật lúc: {weather.time}
          </p>

          <div
            style={{ fontSize: "3.5rem", fontWeight: "bold", margin: "16px 0" }}
          >
            {weather.temperature}°C
          </div>

          <div style={{ fontSize: "1.4rem", marginBottom: "20px" }}>
            {weather.condition}
          </div>

          <div
            style={{
              display: "grid",
              gridTemplateColumns: "1fr 1fr",
              gap: "16px",
              fontSize: "1.1rem",
            }}
          >
            <div>
              <strong>Cảm giác như:</strong> {weather.feelsLike}°C
            </div>
            <div>
              <strong>Độ ẩm:</strong> {weather.humidity}%
            </div>
            <div>
              <strong>Gió:</strong> {weather.windSpeed} km/h
            </div>
          </div>
        </div>
      )}

      {!weather && !error && !loading && (
        <div style={{ color: "#777", marginTop: "40px" }}>
          Nhập tên thành phố và nhấn "Xem thời tiết"
        </div>
      )}
    </div>
  );
}

export default WeatherApp;

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

  • Nhập "Hanoi" → nhấn Xem thời tiết
    → Hiển thị:
    Hanoi
    Cập nhật lúc: 14:30
    28°C
    Trời quang đãng / Mưa nhẹ / ...
    Cảm giác như: 30°C
    Độ ẩm: 75%
    Gió: 12 km/h

  • Nhập "Paris" → tương tự với dữ liệu Paris

  • Nhập "abcxyz" → Error: Không tìm thấy "abcxyz"

  • Không nhập gì → nút disabled

  • Khi đang fetch → nút chuyển thành "Đang tải..." và disabled

Bài 2: Product Catalog với Filter

jsx
/**
 * Tạo product catalog:
 * - Fetch products từ API
 * - Filter by category (dropdown)
 * - Refetch khi category thay đổi
 * - Loading state
 *
 * API: https://fakestoreapi.com/products
 * Categories API: https://fakestoreapi.com/products/categories
 *
 * Requirements:
 * - Fetch categories on mount
 * - Display category dropdown
 * - Fetch products filtered by category
 * - Grid layout
 */
💡 Solution
jsx
/**
 * ProductCatalog - Bài tập về nhà 2: Product catalog with category filter
 * - Fetch all categories on mount
 * - Fetch products filtered by selected category
 * - Dropdown to select category ("All" = no filter)
 * - Loading and error states
 * - Responsive grid layout for products
 *
 * API: https://fakestoreapi.com
 */
import { useState, useEffect } from "react";

function ProductCatalog() {
  const [products, setProducts] = useState([]);
  const [categories, setCategories] = useState([]);
  const [selectedCategory, setSelectedCategory] = useState("all");
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Fetch categories once on mount
  useEffect(() => {
    async function fetchCategories() {
      try {
        const res = await fetch("https://fakestoreapi.com/products/categories");
        if (!res.ok) throw new Error("Failed to load categories");
        const data = await res.json();
        setCategories(["all", ...data]); // Thêm "all" vào đầu
      } catch (err) {
        setError(err.message);
      }
    }
    fetchCategories();
  }, []);

  // Fetch products when selectedCategory changes
  useEffect(() => {
    async function fetchProducts() {
      setLoading(true);
      setError(null);

      try {
        let url = "https://fakestoreapi.com/products";
        if (selectedCategory !== "all") {
          url = `https://fakestoreapi.com/products/category/${encodeURIComponent(selectedCategory)}`;
        }

        const res = await fetch(url);
        if (!res.ok) throw new Error("Failed to load products");
        const data = await res.json();
        setProducts(data);
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchProducts();
  }, [selectedCategory]);

  const handleCategoryChange = (e) => {
    setSelectedCategory(e.target.value);
  };

  if (error) {
    return (
      <div style={{ color: "red", padding: "20px", textAlign: "center" }}>
        Error: {error}
      </div>
    );
  }

  return (
    <div style={{ maxWidth: "1200px", margin: "0 auto", padding: "20px" }}>
      <h1>Product Catalog</h1>

      <div style={{ marginBottom: "24px" }}>
        <label
          htmlFor="category"
          style={{ marginRight: "12px", fontWeight: "500" }}
        >
          Filter by category:
        </label>
        <select
          id="category"
          value={selectedCategory}
          onChange={handleCategoryChange}
          disabled={loading}
          style={{
            padding: "8px 12px",
            fontSize: "16px",
            borderRadius: "6px",
            border: "1px solid #ccc",
            minWidth: "220px",
          }}
        >
          {categories.map((cat) => (
            <option key={cat} value={cat}>
              {cat === "all"
                ? "All Categories"
                : cat.charAt(0).toUpperCase() + cat.slice(1)}
            </option>
          ))}
        </select>
      </div>

      {loading ? (
        <div
          style={{ textAlign: "center", padding: "60px 0", fontSize: "1.2rem" }}
        >
          Loading products...
        </div>
      ) : products.length === 0 ? (
        <div style={{ textAlign: "center", padding: "40px", color: "#666" }}>
          No products found in this category.
        </div>
      ) : (
        <div
          style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
            gap: "24px",
          }}
        >
          {products.map((product) => (
            <div
              key={product.id}
              style={{
                border: "1px solid #ddd",
                borderRadius: "8px",
                overflow: "hidden",
                background: "white",
                boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
                display: "flex",
                flexDirection: "column",
              }}
            >
              <img
                src={product.image}
                alt={product.title}
                style={{
                  width: "100%",
                  height: "220px",
                  objectFit: "contain",
                  padding: "16px",
                  background: "#f9f9f9",
                }}
              />
              <div
                style={{
                  padding: "16px",
                  flexGrow: 1,
                  display: "flex",
                  flexDirection: "column",
                }}
              >
                <h3
                  style={{
                    margin: "0 0 8px 0",
                    fontSize: "1.1rem",
                    lineHeight: "1.4",
                  }}
                >
                  {product.title}
                </h3>
                <p
                  style={{
                    color: "#555",
                    fontSize: "0.95rem",
                    margin: "0 0 12px 0",
                    flexGrow: 1,
                  }}
                >
                  {product.description.substring(0, 120)}...
                </p>
                <div
                  style={{
                    display: "flex",
                    justifyContent: "space-between",
                    alignItems: "center",
                  }}
                >
                  <span
                    style={{
                      fontSize: "1.3rem",
                      fontWeight: "bold",
                      color: "#2e7d32",
                    }}
                  >
                    ${product.price.toFixed(2)}
                  </span>
                  <span style={{ color: "#757575", fontSize: "0.9rem" }}>
                    {product.category}
                  </span>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

export default ProductCatalog;

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

  • Ban đầu: Dropdown có "All Categories", "electronics", "jewelery", "men's clothing", "women's clothing"
    → Hiển thị tất cả ~20 sản phẩm

  • Chọn "electronics" → Loading... → chỉ hiện 5 sản phẩm điện tử (MacBook, iPhone, monitor, v.v.)

  • Chọn "women's clothing" → chỉ hiện quần áo nữ

  • Chọn "All Categories" → quay lại toàn bộ sản phẩm

  • Nếu API lỗi → hiển thị thông báo lỗi đỏ

  • Grid tự động responsive: 4 cột trên màn lớn, 2–3 cột trên tablet, 1 cột trên mobile


Nâng cao (60 phút)

Bài 3: GitHub User Search

jsx
/**
 * Tạo GitHub user search:
 * - Search users by username
 * - Debounce 500ms
 * - Display user info (avatar, repos, followers)
 * - Link to GitHub profile
 * - Rate limit handling
 *
 * API: https://api.github.com/users/{username}
 *
 * Challenges:
 * - Debounced search
 * - Handle 404 (user not found)
 * - Handle rate limits (403)
 * - Display meaningful errors
 */
💡 Solution
jsx
/**
 * GitHubUserSearch - Bài tập về nhà 3: GitHub user search with debounce
 * - Search GitHub users by username (real-time with debounce 500ms)
 * - Display avatar, name, bio, followers, public repos, location
 * - Handle 404 (user not found), rate limit (403), network errors
 * - Loading state during search
 * - Direct link to GitHub profile
 */
import { useState, useEffect } from "react";

function GitHubUserSearch() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // Debounce input
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query.trim());
    }, 500);

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

  // Fetch user when debounced query changes (and is not empty)
  useEffect(() => {
    if (!debouncedQuery || debouncedQuery.length < 2) {
      setUser(null);
      setError(null);
      return;
    }

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

      try {
        const response = await fetch(
          `https://api.github.com/users/${debouncedQuery}`,
        );

        if (response.status === 404) {
          throw new Error("User not found");
        }
        if (response.status === 403) {
          throw new Error("Rate limit exceeded. Please try again later.");
        }
        if (!response.ok) {
          throw new Error(`GitHub API error: ${response.status}`);
        }

        const data = await response.json();

        setUser({
          login: data.login,
          name: data.name,
          avatar: data.avatar_url,
          bio: data.bio,
          followers: data.followers,
          repos: data.public_repos,
          location: data.location,
          profileUrl: data.html_url,
          created: new Date(data.created_at).toLocaleDateString(),
        });
      } catch (err) {
        setError(err.message);
        setUser(null);
      } finally {
        setLoading(false);
      }
    }

    fetchUser();
  }, [debouncedQuery]);

  return (
    <div style={{ maxWidth: "600px", margin: "40px auto", padding: "20px" }}>
      <h1>GitHub User Search</h1>

      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Enter GitHub username (e.g. torvalds, octocat)"
        style={{
          width: "100%",
          padding: "12px",
          fontSize: "16px",
          border: "1px solid #ccc",
          borderRadius: "6px",
          marginBottom: "16px",
        }}
      />

      {loading && (
        <div style={{ textAlign: "center", padding: "20px", color: "#666" }}>
          Searching for @{debouncedQuery}...
        </div>
      )}

      {error && (
        <div
          style={{
            padding: "16px",
            background: "#ffebee",
            color: "#c62828",
            borderRadius: "6px",
            margin: "16px 0",
            textAlign: "center",
          }}
        >
          {error}
        </div>
      )}

      {user && (
        <div
          style={{
            border: "1px solid #ddd",
            borderRadius: "12px",
            overflow: "hidden",
            boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
            background: "white",
          }}
        >
          <div
            style={{
              background: "#0366d6",
              padding: "20px",
              textAlign: "center",
            }}
          >
            <img
              src={user.avatar}
              alt={`${user.login} avatar`}
              style={{
                width: "120px",
                height: "120px",
                borderRadius: "50%",
                border: "4px solid white",
                objectFit: "cover",
              }}
            />
          </div>

          <div style={{ padding: "24px" }}>
            <h2 style={{ margin: "0 0 8px 0" }}>
              {user.name || user.login}
              <small
                style={{
                  color: "#666",
                  fontSize: "0.9rem",
                  marginLeft: "12px",
                }}
              >
                @{user.login}
              </small>
            </h2>

            {user.bio && (
              <p
                style={{
                  color: "#444",
                  margin: "0 0 20px 0",
                  lineHeight: "1.5",
                }}
              >
                {user.bio}
              </p>
            )}

            <div
              style={{
                display: "grid",
                gridTemplateColumns: "1fr 1fr",
                gap: "16px",
                marginBottom: "20px",
              }}
            >
              <div style={{ textAlign: "center" }}>
                <div
                  style={{
                    fontSize: "1.8rem",
                    fontWeight: "bold",
                    color: "#2e7d32",
                  }}
                >
                  {user.followers.toLocaleString()}
                </div>
                <div style={{ color: "#666", fontSize: "0.9rem" }}>
                  Followers
                </div>
              </div>
              <div style={{ textAlign: "center" }}>
                <div
                  style={{
                    fontSize: "1.8rem",
                    fontWeight: "bold",
                    color: "#1976d2",
                  }}
                >
                  {user.repos}
                </div>
                <div style={{ color: "#666", fontSize: "0.9rem" }}>
                  Public Repos
                </div>
              </div>
            </div>

            {user.location && (
              <p style={{ color: "#555", margin: "0 0 16px 0" }}>
                📍 {user.location}
              </p>
            )}

            <p
              style={{
                color: "#777",
                fontSize: "0.9rem",
                margin: "0 0 20px 0",
              }}
            >
              Joined GitHub: {user.created}
            </p>

            <a
              href={user.profileUrl}
              target="_blank"
              rel="noopener noreferrer"
              style={{
                display: "inline-block",
                padding: "12px 24px",
                background: "#24292e",
                color: "white",
                textDecoration: "none",
                borderRadius: "6px",
                fontWeight: "500",
              }}
            >
              View Profile on GitHub →
            </a>
          </div>
        </div>
      )}

      {!user && !loading && !error && debouncedQuery.length >= 2 && (
        <div style={{ textAlign: "center", color: "#777", padding: "40px 0" }}>
          No user found for "{debouncedQuery}"
        </div>
      )}

      {debouncedQuery.length < 2 && query.length > 0 && (
        <div style={{ textAlign: "center", color: "#888", padding: "20px 0" }}>
          Type at least 2 characters to search
        </div>
      )}
    </div>
  );
}

export default GitHubUserSearch;

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

  • Gõ "torvalds" → sau ~500ms: hiển thị Linus Torvalds

    • Avatar lớn
    • Tên: Linus Torvalds @torvalds
    • Bio (nếu có)
    • Followers: ~70k+
    • Public Repos: hàng trăm
    • Location: (nếu công khai)
    • Nút View Profile on GitHub
  • Gõ "octocat" → hiển thị Octocat (mascot của GitHub)

  • Gõ "nonexistentuser123456" → "User not found"

  • Gõ quá nhanh → debounce ngăn fetch liên tục

  • Nếu bị rate limit (thử nhiều lần) → "Rate limit exceeded. Please try again later."

Bài 4: Multi-Tab Data Manager

jsx
/**
 * Tạo tabbed interface:
 * - Tabs: Users, Posts, Comments
 * - Fetch data khi switch tab
 * - Cache fetched data (don't refetch same tab)
 * - Refresh button per tab
 *
 * Requirements:
 * - Tab navigation
 * - Lazy load data (only fetch when tab clicked)
 * - Cache in state
 * - Individual refresh buttons
 *
 * Challenge:
 * - How to cache without re-fetching?
 * - Hint: Track which tabs have been loaded
 */
💡 Solution
jsx
/**
 * MultiTabDataManager - Bài tập về nhà 4: Tabbed interface with lazy loading & caching
 * - Tabs: Users / Posts / Comments
 * - Fetch data chỉ khi tab được chọn lần đầu
 * - Cache kết quả đã fetch (không fetch lại khi quay lại tab)
 * - Nút Refresh riêng cho từng tab
 * - Loading & error state riêng biệt cho mỗi tab
 */
import { useState, useEffect } from "react";

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

function TabButton({ label, isActive, onClick }) {
  return (
    <button
      onClick={onClick}
      style={{
        padding: "12px 24px",
        fontSize: "16px",
        fontWeight: isActive ? "bold" : "normal",
        background: isActive ? "#1976d2" : "#e0e0e0",
        color: isActive ? "white" : "#333",
        border: "none",
        borderRadius: "8px 8px 0 0",
        cursor: "pointer",
        marginRight: "4px",
      }}
    >
      {label}
    </button>
  );
}

function DataTab({ endpoint, title, renderItem }) {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [lastFetched, setLastFetched] = useState(null);

  const fetchData = async () => {
    setLoading(true);
    setError(null);

    try {
      const res = await fetch(`${API_BASE}/${endpoint}?_limit=8`);
      if (!res.ok) throw new Error(`Failed to load ${title.toLowerCase()}`);
      const result = await res.json();
      setData(result);
      setLastFetched(new Date());
      setLoading(false);
    } catch (err) {
      setError(err.message);
      setLoading(false);
    }
  };

  // Chỉ fetch lần đầu khi component mount (lazy load)
  useEffect(() => {
    // Vì tab chỉ render khi active → effect chạy khi tab được chọn lần đầu
    fetchData();
  }, []); // empty deps → chỉ chạy 1 lần khi tab mount

  const handleRefresh = () => {
    fetchData();
  };

  if (loading) {
    return (
      <div style={{ padding: "40px", textAlign: "center" }}>
        Loading {title}...
      </div>
    );
  }

  if (error) {
    return (
      <div style={{ padding: "20px", textAlign: "center", color: "#c62828" }}>
        <p>Error: {error}</p>
        <button
          onClick={handleRefresh}
          style={{
            padding: "10px 20px",
            background: "#d32f2f",
            color: "white",
            border: "none",
            borderRadius: "6px",
            cursor: "pointer",
            marginTop: "12px",
          }}
        >
          Retry
        </button>
      </div>
    );
  }

  return (
    <div>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: "16px",
        }}
      >
        <h2 style={{ margin: 0 }}>
          {title} ({data.length})
        </h2>
        <div>
          {lastFetched && (
            <small style={{ color: "#666", marginRight: "16px" }}>
              Last updated: {lastFetched.toLocaleTimeString()}
            </small>
          )}
          <button
            onClick={handleRefresh}
            disabled={loading}
            style={{
              padding: "8px 16px",
              background: "#4CAF50",
              color: "white",
              border: "none",
              borderRadius: "6px",
              cursor: loading ? "not-allowed" : "pointer",
            }}
          >
            {loading ? "Refreshing..." : "Refresh"}
          </button>
        </div>
      </div>

      <div style={{ display: "grid", gap: "16px" }}>
        {data.map((item) => renderItem(item))}
      </div>
    </div>
  );
}

function MultiTabDataManager() {
  const [activeTab, setActiveTab] = useState("users");

  const tabs = [
    { id: "users", label: "Users" },
    { id: "posts", label: "Posts" },
    { id: "comments", label: "Comments" },
  ];

  return (
    <div style={{ maxWidth: "1000px", margin: "40px auto", padding: "20px" }}>
      <h1>Multi-Tab Data Manager</h1>

      <div
        style={{
          display: "flex",
          marginBottom: "24px",
          borderBottom: "2px solid #ddd",
        }}
      >
        {tabs.map((tab) => (
          <TabButton
            key={tab.id}
            label={tab.label}
            isActive={activeTab === tab.id}
            onClick={() => setActiveTab(tab.id)}
          />
        ))}
      </div>

      <div
        style={{
          padding: "20px",
          background: "#f9f9f9",
          borderRadius: "0 8px 8px 8px",
        }}
      >
        {activeTab === "users" && (
          <DataTab
            endpoint="users"
            title="Users"
            renderItem={(user) => (
              <div
                key={user.id}
                style={{
                  padding: "16px",
                  border: "1px solid #ddd",
                  borderRadius: "8px",
                  background: "white",
                }}
              >
                <h3 style={{ margin: "0 0 8px 0" }}>{user.name}</h3>
                <p style={{ margin: "0 0 4px 0", color: "#555" }}>
                  {user.email}
                </p>
                <p style={{ margin: 0, color: "#777", fontSize: "0.9rem" }}>
                  {user.company.name} • {user.website}
                </p>
              </div>
            )}
          />
        )}

        {activeTab === "posts" && (
          <DataTab
            endpoint="posts"
            title="Posts"
            renderItem={(post) => (
              <div
                key={post.id}
                style={{
                  padding: "16px",
                  border: "1px solid #ddd",
                  borderRadius: "8px",
                  background: "white",
                }}
              >
                <h3 style={{ margin: "0 0 8px 0" }}>{post.title}</h3>
                <p style={{ margin: 0, color: "#444" }}>
                  {post.body.substring(0, 150)}...
                </p>
              </div>
            )}
          />
        )}

        {activeTab === "comments" && (
          <DataTab
            endpoint="comments"
            title="Comments"
            renderItem={(comment) => (
              <div
                key={comment.id}
                style={{
                  padding: "16px",
                  border: "1px solid #ddd",
                  borderRadius: "8px",
                  background: "white",
                }}
              >
                <div
                  style={{
                    display: "flex",
                    justifyContent: "space-between",
                    marginBottom: "8px",
                  }}
                >
                  <strong style={{ color: "#1976d2" }}>{comment.name}</strong>
                  <small style={{ color: "#777" }}>{comment.email}</small>
                </div>
                <p style={{ margin: 0, color: "#444" }}>{comment.body}</p>
              </div>
            )}
          />
        )}
      </div>

      <div
        style={{
          marginTop: "32px",
          padding: "16px",
          background: "#e3f2fd",
          borderRadius: "8px",
        }}
      >
        <h3 style={{ marginTop: 0 }}>Cách hoạt động:</h3>
        <ul>
          <li>Dữ liệu chỉ fetch khi tab được chọn lần đầu (lazy loading)</li>
          <li>Quay lại tab cũ → dữ liệu từ cache (state), không fetch lại</li>
          <li>Mỗi tab có nút Refresh riêng để tải lại dữ liệu</li>
          <li>Loading & error state độc lập cho từng tab</li>
        </ul>
      </div>
    </div>
  );
}

export default MultiTabDataManager;

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

  • Mở trang → tab "Users" active → tự động fetch và hiển thị 8 users
  • Chuyển sang tab "Posts" → fetch posts → hiển thị 8 bài post
  • Chuyển sang "Comments" → fetch comments
  • Quay lại tab "Users" → hiển thị dữ liệu cũ ngay lập tức (không loading lại)
  • Nhấn "Refresh" trong tab Posts → loading... → tải lại 8 posts mới nhất
  • Mỗi tab có trạng thái loading/error/refresh riêng biệt
  • Không fetch lại dữ liệu khi chuyển tab trừ khi nhấn Refresh

📚 TÀI LIỆU THAM KHẢO

Bắt buộc đọc

  1. fetch API MDN

  2. React Docs - Data Fetching

Đọc thêm

  1. Async/Await Error Handling

    • try/catch patterns
    • Promise rejection
  2. HTTP Status Codes

    • 2xx Success
    • 4xx Client errors
    • 5xx Server errors

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

Kiến thức nền (đã học):

  • Ngày 17: Dependencies

    • Kết nối: [userId] trigger refetch
  • Ngày 18: Cleanup

    • Kết nối: isCancelled flag cho async

Hướng tới (sẽ học):

  • Ngày 20: Advanced Patterns
    • AbortController
    • Race conditions
    • Parallel requests

💡 SENIOR INSIGHTS

Production Patterns

jsx
// ✅ PRODUCTION FETCH TEMPLATE
function useDataFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isCancelled = false;

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

        const response = await fetch(url);

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

        const data = await response.json();

        if (!isCancelled) {
          setData(data);
          setLoading(false);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err.message);
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      isCancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

🎯 NGÀY MAI: Data Fetching - Advanced Patterns

Preview:

  • AbortController
  • Race conditions
  • Dependent requests
  • Parallel fetching

🔥 Chuẩn bị:

  • Practice hôm nay
  • Ôn cleanup (Ngày 18)

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

Bạn đã:

  • ✅ Master fetch API trong useEffect
  • ✅ Implement Loading/Error/Success states
  • ✅ Handle HTTP errors properly
  • ✅ Apply refetch với dependencies
  • ✅ Build production-ready data fetching

Tomorrow: Advanced patterns để handle complex scenarios! 🚀

Personal tech knowledge base