📅 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:
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)
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)
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:
// ❌ 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 unmountVấ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:
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:
- ✅ useEffect: Fetch là side effect, đúng chỗ
- ✅ Dependencies [userId]: Refetch khi cần
- ✅ 3 states: Loading/Error/Success
- ✅ Error handling: Catch network & HTTP errors
- ✅ 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) → SUCCESSAnalogy dễ hiểu:
Data fetching như đặt hàng online:
- Loading: "Đang xử lý đơn hàng..."
- Success: "Đơn hàng đã giao!" → Hiển thị sản phẩm
- 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"
// ❌ 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"
// ❌ 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"
// ❌ 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 ⭐
/**
* 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 ⭐⭐
/**
* 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 ⭐⭐⭐
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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ụ:- Leanne Graham (Sincere@april.biz)
- Clementine Bauch (Nathan@yesenia.net)
- 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)
/**
* 🎯 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
/**
* 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...
- Bên phải chuyển sang "Loading comments..." → hiển thị danh sách comments của post đó Ví dụ:
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)
/**
* 🎯 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
/**
* 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
| Pattern | Code | Pros | Cons | Use Case |
|---|---|---|---|---|
| .then() chain | fetch(url).then(res => res.json()).then(setData) | ✅ Simple ✅ No async wrapper | ❌ Callback hell ❌ Harder error handling | Simple fetches |
| async/await | const res = await fetch(url); const data = await res.json(); | ✅ Readable ✅ try/catch | ❌ Need wrapper function | Complex logic |
| Promise.all | Promise.all([fetch(url1), fetch(url2)]) | ✅ Parallel ✅ Fast | ❌ All-or-nothing | Multiple endpoints |
Bảng So Sánh: State Patterns
| States | Code | Pros | Cons | When to Use |
|---|---|---|---|---|
| 3 separate states | const [data, setData] = useState(null);const [loading, setLoading] = useState(true);const [error, setError] = useState(null); | ✅ Clear ✅ Easy to understand | ❌ 3 setStates | Standard approach |
| Single state object | const [state, setState] = useState({ data: null, loading: true, error: null }); | ✅ Atomic updates ✅ Fewer setStates | ❌ More verbose updates | Complex state |
| useReducer | const [state, dispatch] = useReducer(reducer, initialState); | ✅ Predictable ✅ Testable | ❌ More boilerplate | Will 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 🚨
/**
* 🐛 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 manuallyBug #2: setState After Unmount ⚠️
/**
* 🐛 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 leaksBug #3: Infinite Fetch Loop 🔁
/**
* 🐛 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
/**
* 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
/**
* 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=...¤t=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}¤t=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/hNhậ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
/**
* 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
/**
* 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ẩmChọ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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
fetch API MDN
- https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- response.ok explanation
- Error handling
React Docs - Data Fetching
- https://react.dev/learn/synchronizing-with-effects#fetching-data
- Best practices
- Common pitfalls
Đọc thêm
Async/Await Error Handling
- try/catch patterns
- Promise rejection
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
// ✅ 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! 🚀