📅 NGÀY 28: useReducer + useEffect - Async Actions Pattern
📍 Vị trí: Phase 3, Tuần 6, Ngày 28/45
⏱️ Thời lượng: 3-4 giờ
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Kết hợp được useReducer + useEffect để handle async operations
- [ ] Implement được loading/success/error states pattern với reducer
- [ ] Xử lý được race conditions và request cancellation
- [ ] Áp dụng được optimistic updates cho better UX
- [ ] Thiết kế được retry logic và error recovery
🤔 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:
Reducer có được phép async (await fetch) không? Tại sao?
- Gợi ý: Reducer phải pure function...
useEffect cleanup function chạy khi nào?
- Gợi ý: Component unmount, dependencies change...
Race condition trong data fetching là gì?
- Ví dụ: User search "abc" → "abcd" nhanh → response nào hiển thị?
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Hãy tưởng tượng bạn đang build User Profile Page với data từ API:
// ❌ VẤN ĐỀ: useState cho async operations
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
setError(null); // ⚠️ Dễ quên!
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
setLoading(false); // ⚠️ Duplicate
} catch (err) {
setError(err.message);
setLoading(false); // ⚠️ Duplicate
setUser(null); // ⚠️ Dễ quên!
}
};
fetchUser();
}, [userId]);
// 😱 Nhiều edge cases không handle:
// - Component unmount giữa chừng request?
// - userId change trước khi request complete?
// - Retry khi error?
// - Optimistic update?
}Vấn đề:
- 🔴 3 separate states → Dễ out of sync
- 🔴 Duplicate logic → setLoading(false) ở nhiều chỗ
- 🔴 Missing cleanup → Race conditions
- 🔴 Inconsistent states → loading=true + error != null?
- 🔴 Hard to extend → Thêm retry, optimistic updates?
1.2 Giải Pháp: useReducer + useEffect Pattern
Core Idea:
- ✅ useReducer quản lý state (sync)
- ✅ useEffect handle side effects (async)
- ✅ dispatch trong useEffect để update state
// ✅ GIẢI PHÁP: Centralized async state
const initialState = {
data: null,
loading: false,
error: null,
};
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { data: null, loading: true, error: null };
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null };
case 'FETCH_ERROR':
return { data: null, loading: false, error: action.payload };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function UserProfile({ userId }) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
}
};
fetchUser();
return () => controller.abort(); // Cleanup!
}, [userId]);
// ✅ Centralized state, clear transitions
}Lợi ích:
- ✅ State transitions rõ ràng → idle → loading → success/error
- ✅ Impossible states prevented → Không thể loading=true + data != null
- ✅ Easy to test → Test reducer riêng
- ✅ Easy to extend → Add retry, optimistic updates
1.3 Mental Model
┌────────────────────────────────────────────────┐
│ COMPONENT │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ useReducer (Sync State) │ │
│ │ │ │
│ │ State: { data, loading, error } │ │
│ │ │ │
│ │ Reducer handles: │ │
│ │ • FETCH_START → loading=true │ │
│ │ • FETCH_SUCCESS → data=... │ │
│ │ • FETCH_ERROR → error=... │ │
│ └──────────────────────────────────────┘ │
│ ↑ │
│ │ dispatch(action) │
│ │ │
│ ┌──────────────────────────────────────┐ │
│ │ useEffect (Async Side Effects) │ │
│ │ │ │
│ │ 1. dispatch(FETCH_START) │ │
│ │ 2. await fetch(...) │ │
│ │ 3. dispatch(FETCH_SUCCESS/ERROR) │ │
│ │ │ │
│ │ Cleanup: abort request │ │
│ └──────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────┘
Flow:
1. Effect runs → dispatch FETCH_START
2. Reducer updates state → loading=true
3. Component re-renders (loading UI)
4. Async fetch completes
5. Effect dispatches FETCH_SUCCESS/ERROR
6. Reducer updates state → data/error
7. Component re-renders (data/error UI)Analogy: useReducer + useEffect giống State Machine với Async Transitions
- State Machine (Reducer): Định nghĩa states & transitions
- Async Engine (useEffect): Trigger transitions dựa trên external events
- Dispatcher: Bridge giữa async world và sync state
1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "Có thể async trong reducer"
- ✅ Sự thật: Reducer PHẢI synchronous, pure function. Async trong useEffect!
❌ Hiểu lầm 2: "Không cần cleanup khi fetch data"
- ✅ Sự thật: LUÔN cleanup để avoid race conditions, memory leaks
❌ Hiểu lầm 3: "Loading state đơn giản: true/false"
- ✅ Sự thật: Cần distinguish: idle, loading, success, error (4 states)
❌ Hiểu lầm 4: "dispatch trong useEffect gây infinite loop"
- ✅ Sự thật: Chỉ loop nếu dispatch dependency. Dispatch function stable, safe!
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Basic Async Pattern - Fetch User ⭐
import { useReducer, useEffect } from 'react';
// 🎯 ACTION TYPES
const ActionTypes = {
FETCH_START: 'FETCH_START',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
};
// 🏭 REDUCER
function userReducer(state, action) {
switch (action.type) {
case ActionTypes.FETCH_START:
return {
data: null,
loading: true,
error: null,
};
case ActionTypes.FETCH_SUCCESS:
return {
data: action.payload,
loading: false,
error: null,
};
case ActionTypes.FETCH_ERROR:
return {
data: null,
loading: false,
error: action.payload,
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 🎯 COMPONENT
function UserProfile({ userId }) {
const initialState = {
data: null,
loading: false,
error: null,
};
const [state, dispatch] = useReducer(userReducer, initialState);
useEffect(() => {
// ⚠️ CRITICAL: AbortController for cleanup
const controller = new AbortController();
const fetchUser = async () => {
// 1️⃣ Start loading
dispatch({ type: ActionTypes.FETCH_START });
try {
// 2️⃣ Fetch data
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal: controller.signal }, // ✅ Attach abort signal
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// 3️⃣ Success
dispatch({
type: ActionTypes.FETCH_SUCCESS,
payload: data,
});
} catch (error) {
// ⚠️ Don't dispatch if aborted (component unmounted)
if (error.name !== 'AbortError') {
dispatch({
type: ActionTypes.FETCH_ERROR,
payload: error.message,
});
}
}
};
fetchUser();
// 4️⃣ Cleanup: abort request if userId changes or unmount
return () => {
controller.abort();
};
}, [userId]); // Re-run khi userId thay đổi
// 🎨 RENDER
if (state.loading) {
return <div>Loading user {userId}...</div>;
}
if (state.error) {
return <div style={{ color: 'red' }}>Error: {state.error}</div>;
}
if (!state.data) {
return <div>No data</div>;
}
return (
<div>
<h2>{state.data.name}</h2>
<p>Email: {state.data.email}</p>
<p>Phone: {state.data.phone}</p>
<p>Website: {state.data.website}</p>
</div>
);
}
// 🎯 DEMO APP
function App() {
const [userId, setUserId] = useState(1);
return (
<div>
<h1>User Profile</h1>
<div>
<button onClick={() => setUserId(1)}>User 1</button>
<button onClick={() => setUserId(2)}>User 2</button>
<button onClick={() => setUserId(3)}>User 3</button>
</div>
<UserProfile userId={userId} />
</div>
);
}🎯 Key Points:
State Transitions:
Initial: { data: null, loading: false, error: null } ↓ FETCH_START Loading: { data: null, loading: true, error: null } ↓ FETCH_SUCCESS Success: { data: {...}, loading: false, error: null } ↓ FETCH_ERROR (if error) Error: { data: null, loading: false, error: "..." }AbortController:
- Prevents race conditions
- Cancels in-flight requests
- Essential for cleanup!
Error Handling:
- Check
error.name !== 'AbortError' - Don't dispatch if request was aborted
- Check
Demo 2: Advanced Pattern - Search with Debounce ⭐⭐
import { useReducer, useEffect, useState, useRef } from 'react';
// 🎯 ACTION TYPES
const ActionTypes = {
SEARCH_START: 'SEARCH_START',
SEARCH_SUCCESS: 'SEARCH_SUCCESS',
SEARCH_ERROR: 'SEARCH_ERROR',
CLEAR_RESULTS: 'CLEAR_RESULTS',
};
// 🏭 REDUCER
function searchReducer(state, action) {
switch (action.type) {
case ActionTypes.SEARCH_START:
return {
...state,
loading: true,
error: null,
};
case ActionTypes.SEARCH_SUCCESS:
return {
...state,
results: action.payload,
loading: false,
error: null,
};
case ActionTypes.SEARCH_ERROR:
return {
...state,
results: [],
loading: false,
error: action.payload,
};
case ActionTypes.CLEAR_RESULTS:
return {
results: [],
loading: false,
error: null,
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 🔍 SEARCH COMPONENT
function UserSearch() {
const [searchTerm, setSearchTerm] = useState('');
const initialState = {
results: [],
loading: false,
error: null,
};
const [state, dispatch] = useReducer(searchReducer, initialState);
// ✅ Debounce search
useEffect(() => {
// Clear results nếu search term rỗng
if (!searchTerm.trim()) {
dispatch({ type: ActionTypes.CLEAR_RESULTS });
return;
}
// Debounce: Chờ 500ms sau khi user ngừng typing
const debounceTimer = setTimeout(() => {
const controller = new AbortController();
const searchUsers = async () => {
dispatch({ type: ActionTypes.SEARCH_START });
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users?name_like=${searchTerm}`,
{ signal: controller.signal },
);
const data = await response.json();
dispatch({
type: ActionTypes.SEARCH_SUCCESS,
payload: data,
});
} catch (error) {
if (error.name !== 'AbortError') {
dispatch({
type: ActionTypes.SEARCH_ERROR,
payload: error.message,
});
}
}
};
searchUsers();
// Cleanup debounce timer
return () => {
controller.abort();
};
}, 500); // 500ms debounce
// Cleanup timer nếu searchTerm thay đổi trước 500ms
return () => {
clearTimeout(debounceTimer);
};
}, [searchTerm]);
return (
<div>
<h2>Search Users</h2>
<input
type='text'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder='Search users...'
/>
{state.loading && <p>Searching...</p>}
{state.error && <p style={{ color: 'red' }}>Error: {state.error}</p>}
{state.results.length > 0 && (
<ul>
{state.results.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
)}
{!state.loading && state.results.length === 0 && searchTerm && (
<p>No results found</p>
)}
</div>
);
}🎯 Advanced Concepts:
Debouncing:
- Wait 500ms after user stops typing
- Reduces API calls
- Better UX + performance
Cleanup Chain:
User types "a" → setTimeout(500ms) User types "ab" (before 500ms) → clearTimeout + new setTimeout → Only final "ab" triggers API callEmpty State Handling:
- Clear results khi searchTerm rỗng
- Show "No results" khi có search nhưng không tìm thấy
Demo 3: Optimistic Updates ⭐⭐⭐
import { useReducer, useEffect, useState } from 'react';
// 🎯 ACTION TYPES
const ActionTypes = {
// Fetch
FETCH_START: 'FETCH_START',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
// Optimistic
ADD_TODO_OPTIMISTIC: 'ADD_TODO_OPTIMISTIC',
ADD_TODO_CONFIRMED: 'ADD_TODO_CONFIRMED',
ADD_TODO_FAILED: 'ADD_TODO_FAILED',
TOGGLE_TODO_OPTIMISTIC: 'TOGGLE_TODO_OPTIMISTIC',
TOGGLE_TODO_CONFIRMED: 'TOGGLE_TODO_CONFIRMED',
TOGGLE_TODO_FAILED: 'TOGGLE_TODO_FAILED',
};
// 🏭 REDUCER
function todosReducer(state, action) {
switch (action.type) {
case ActionTypes.FETCH_START:
return { ...state, loading: true };
case ActionTypes.FETCH_SUCCESS:
return {
todos: action.payload,
loading: false,
error: null,
};
case ActionTypes.FETCH_ERROR:
return { ...state, loading: false, error: action.payload };
// ✅ Optimistic Add
case ActionTypes.ADD_TODO_OPTIMISTIC: {
const optimisticTodo = {
id: `temp-${Date.now()}`, // Temporary ID
title: action.payload.title,
completed: false,
_optimistic: true, // Flag để track
};
return {
...state,
todos: [...state.todos, optimisticTodo],
};
}
case ActionTypes.ADD_TODO_CONFIRMED: {
// Replace temporary todo với real todo from server
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.tempId
? { ...action.payload.todo, _optimistic: false }
: todo,
),
};
}
case ActionTypes.ADD_TODO_FAILED: {
// Rollback: Remove optimistic todo
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.tempId),
error: action.payload.error,
};
}
// ✅ Optimistic Toggle
case ActionTypes.TOGGLE_TODO_OPTIMISTIC: {
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed, _optimistic: true }
: todo,
),
};
}
case ActionTypes.TOGGLE_TODO_CONFIRMED: {
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, _optimistic: false }
: todo,
),
};
}
case ActionTypes.TOGGLE_TODO_FAILED: {
// Rollback: Toggle back
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed, _optimistic: false }
: todo,
),
error: action.payload.error,
};
}
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 🎯 COMPONENT
function OptimisticTodos() {
const initialState = {
todos: [],
loading: false,
error: null,
};
const [state, dispatch] = useReducer(todosReducer, initialState);
const [inputValue, setInputValue] = useState('');
// Fetch initial todos
useEffect(() => {
const fetchTodos = async () => {
dispatch({ type: ActionTypes.FETCH_START });
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/todos?_limit=5',
);
const data = await response.json();
dispatch({ type: ActionTypes.FETCH_SUCCESS, payload: data });
} catch (error) {
dispatch({ type: ActionTypes.FETCH_ERROR, payload: error.message });
}
};
fetchTodos();
}, []);
// ✅ Optimistic Add Todo
const handleAddTodo = async (title) => {
const tempId = `temp-${Date.now()}`;
// 1️⃣ Optimistic update (instant UI update)
dispatch({
type: ActionTypes.ADD_TODO_OPTIMISTIC,
payload: { title },
});
try {
// 2️⃣ API call (background)
const response = await fetch(
'https://jsonplaceholder.typicode.com/todos',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed: false }),
},
);
const newTodo = await response.json();
// 3️⃣ Confirm (replace temp with real)
dispatch({
type: ActionTypes.ADD_TODO_CONFIRMED,
payload: { tempId, todo: newTodo },
});
} catch (error) {
// 4️⃣ Rollback on error
dispatch({
type: ActionTypes.ADD_TODO_FAILED,
payload: { tempId, error: error.message },
});
}
};
// ✅ Optimistic Toggle
const handleToggle = async (id) => {
const todo = state.todos.find((t) => t.id === id);
// 1️⃣ Optimistic update
dispatch({
type: ActionTypes.TOGGLE_TODO_OPTIMISTIC,
payload: { id },
});
try {
// 2️⃣ API call
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
});
// 3️⃣ Confirm
dispatch({
type: ActionTypes.TOGGLE_TODO_CONFIRMED,
payload: { id },
});
} catch (error) {
// 4️⃣ Rollback
dispatch({
type: ActionTypes.TOGGLE_TODO_FAILED,
payload: { id, error: error.message },
});
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
handleAddTodo(inputValue);
setInputValue('');
}
};
return (
<div>
<h2>Optimistic Todos</h2>
{state.error && (
<div style={{ color: 'red', padding: '10px', background: '#fee' }}>
Error: {state.error}
</div>
)}
<form onSubmit={handleSubmit}>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder='Add todo...'
/>
<button type='submit'>Add</button>
</form>
{state.loading ? (
<p>Loading...</p>
) : (
<ul>
{state.todos.map((todo) => (
<li
key={todo.id}
style={{
opacity: todo._optimistic ? 0.5 : 1, // Visual feedback
transition: 'opacity 0.3s',
}}
>
<input
type='checkbox'
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.title}
</span>
{todo._optimistic && <span> (saving...)</span>}
</li>
))}
</ul>
)}
</div>
);
}🎯 Optimistic Updates Pattern:
Flow:
User action ↓ Dispatch optimistic update (instant UI) ↓ API call (background) ↓ Success → Confirm (replace temp with real) Error → Rollback (revert to previous state)Benefits:
- ✅ Instant feedback (no loading spinners)
- ✅ Better UX (feels faster)
- ✅ Graceful error handling (rollback visible)
Trade-offs:
- ➖ More complex code
- ➖ Need rollback logic
- ➖ Temporary IDs management
🔨 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 async pattern
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: Custom hooks, Context
*
* Requirements:
* 1. Fetch posts from API khi component mount
* 2. Hiển thị loading state
* 3. Hiển thị error state nếu có
* 4. Hiển thị list posts khi success
* 5. Cleanup request khi component unmount
*
* API: https://jsonplaceholder.typicode.com/posts?_limit=10
*
* 💡 Gợi ý:
* - State shape: { posts: [], loading: false, error: null }
* - Actions: FETCH_START, FETCH_SUCCESS, FETCH_ERROR
* - useEffect với AbortController
*/
// TODO: Implement PostsList component
// Starter Code:
function PostsList() {
// TODO: Setup reducer
// TODO: Setup useEffect
// TODO: Render UI
return <div>Implement me!</div>;
}
// Expected UI:
// Loading: "Loading posts..."
// Error: "Error: [error message]"
// Success: List of 10 post titles💡 Solution
/**
* PostsList component
* Fetch và hiển thị danh sách 10 bài post từ JSONPlaceholder
* Sử dụng useReducer + useEffect với AbortController để tránh race condition
*/
import { useReducer, useEffect } from 'react';
const initialState = {
posts: [],
loading: false,
error: null,
};
const ActionTypes = {
FETCH_START: 'FETCH_START',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
};
function postsReducer(state, action) {
switch (action.type) {
case ActionTypes.FETCH_START:
return {
posts: [],
loading: true,
error: null,
};
case ActionTypes.FETCH_SUCCESS:
return {
posts: action.payload,
loading: false,
error: null,
};
case ActionTypes.FETCH_ERROR:
return {
posts: [],
loading: false,
error: action.payload,
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function PostsList() {
const [state, dispatch] = useReducer(postsReducer, initialState);
useEffect(() => {
const controller = new AbortController();
const fetchPosts = async () => {
dispatch({ type: ActionTypes.FETCH_START });
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts?_limit=10',
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
dispatch({ type: ActionTypes.FETCH_SUCCESS, payload: data });
} catch (err) {
// Không dispatch error nếu request bị abort
if (err.name !== 'AbortError') {
dispatch({
type: ActionTypes.FETCH_ERROR,
payload: err.message || 'Failed to fetch posts',
});
}
}
};
fetchPosts();
// Cleanup: huỷ request khi component unmount hoặc effect chạy lại
return () => {
controller.abort();
};
}, []); // Chỉ fetch 1 lần khi mount
if (state.loading) {
return <div>Loading posts...</div>;
}
if (state.error) {
return <div style={{ color: 'red' }}>Error: {state.error}</div>;
}
if (state.posts.length === 0) {
return <div>No posts found</div>;
}
return (
<div>
<h3>Posts (10 latest)</h3>
<ul>
{state.posts.map((post) => (
<li key={post.id}>
<strong>{post.title}</strong>
<p>{post.body.substring(0, 120)}...</p>
</li>
))}
</ul>
</div>
);
}
export default PostsList;
/*
Kết quả ví dụ khi render thành công:
Posts (10 latest)
• sunt aut facere repellat provident occaecati excepturi optio reprehenderit
quia et suscipit...
• qui est esse
est rerum tempore vitae sequi sint nihil reprehenderit...
• ea molestias quasi exercitationem repellat qui ipsa sit aut
et iusto sed quo iure...
(và 7 bài tiếp theo)
*/⭐⭐ Level 2: Nhận Biết Pattern (25 phút)
/**
* 🎯 Mục tiêu: Implement pagination với useReducer + useEffect
* ⏱️ Thời gian: 25 phút
*
* Scenario: User List với pagination
*
* Requirements:
* - Display 5 users per page
* - Previous/Next buttons
* - Fetch data khi page thay đổi
* - Loading state cho mỗi page change
* - Disable Previous ở page 1
* - Disable Next ở page cuối (page 4)
*
* API: https://jsonplaceholder.typicode.com/users?_page=${page}&_limit=5
*
* State shape:
* {
* users: [],
* loading: false,
* error: null,
* currentPage: 1,
* totalPages: 4
* }
*
* Actions:
* - FETCH_START
* - FETCH_SUCCESS
* - FETCH_ERROR
* - SET_PAGE
*
* 🤔 CHALLENGE:
* - Làm sao tránh fetch duplicate khi spam click Next?
* - Cleanup request khi page change nhanh?
*/
// TODO: Implement PaginatedUsers component💡 Solution
/**
* PaginatedUsers component
* Hiển thị danh sách users với phân trang (5 users/trang)
* Sử dụng useReducer + useEffect + AbortController
* Xử lý loading, error, disable nút khi ở trang đầu/cuối
* Ngăn fetch trùng lặp khi click nhanh (dùng flag isFetching)
*/
import { useReducer, useEffect, useRef } from 'react';
const USERS_PER_PAGE = 5;
const TOTAL_USERS = 10; // jsonplaceholder có 10 users → 2 trang thực tế, giả sử 4 trang
const initialState = {
users: [],
loading: false,
error: null,
currentPage: 1,
totalPages: 4, // giả định, thực tế sẽ tính từ header hoặc total count
};
const ActionTypes = {
FETCH_START: 'FETCH_START',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
SET_PAGE: 'SET_PAGE',
};
function usersReducer(state, action) {
switch (action.type) {
case ActionTypes.FETCH_START:
return {
...state,
loading: true,
error: null,
};
case ActionTypes.FETCH_SUCCESS:
return {
...state,
users: action.payload,
loading: false,
error: null,
};
case ActionTypes.FETCH_ERROR:
return {
...state,
loading: false,
error: action.payload,
};
case ActionTypes.SET_PAGE:
return {
...state,
currentPage: action.payload,
users: [], // clear cũ để tránh flash content cũ
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function PaginatedUsers() {
const [state, dispatch] = useReducer(usersReducer, initialState);
const isFetching = useRef(false); // ngăn fetch trùng khi click nhanh
const fetchUsers = async (page, signal) => {
if (isFetching.current) return;
isFetching.current = true;
dispatch({ type: ActionTypes.FETCH_START });
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users?_page=${page}&_limit=${USERS_PER_PAGE}`,
{ signal },
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
dispatch({ type: ActionTypes.FETCH_SUCCESS, payload: data });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({
type: ActionTypes.FETCH_ERROR,
payload: err.message || 'Failed to load users',
});
}
} finally {
isFetching.current = false;
}
};
useEffect(() => {
const controller = new AbortController();
fetchUsers(state.currentPage, controller.signal);
return () => {
controller.abort();
};
}, [state.currentPage]);
const handlePageChange = (newPage) => {
if (newPage < 1 || newPage > state.totalPages || state.loading) return;
dispatch({ type: ActionTypes.SET_PAGE, payload: newPage });
};
const prevDisabled = state.currentPage === 1 || state.loading;
const nextDisabled = state.currentPage === state.totalPages || state.loading;
return (
<div>
<h3>
Users - Page {state.currentPage} of {state.totalPages}
</h3>
{state.loading && <div>Loading users...</div>}
{state.error && <div style={{ color: 'red' }}>Error: {state.error}</div>}
{!state.loading && !state.error && state.users.length === 0 && (
<div>No users found</div>
)}
{state.users.length > 0 && (
<ul>
{state.users.map((user) => (
<li key={user.id}>
{user.name}
<br />
<small>
{user.email} • {user.company.name}
</small>
</li>
))}
</ul>
)}
<div style={{ marginTop: '20px' }}>
<button
onClick={() => handlePageChange(state.currentPage - 1)}
disabled={prevDisabled}
>
Previous
</button>
<span style={{ margin: '0 16px' }}>Page {state.currentPage}</span>
<button
onClick={() => handlePageChange(state.currentPage + 1)}
disabled={nextDisabled}
>
Next
</button>
</div>
</div>
);
}
export default PaginatedUsers;
/*
Kết quả ví dụ khi render:
Users - Page 1 of 4
• Leanne Graham
sincere@april.biz • Romaguera-Crona
• Ervin Howell
shanna@melissa.tv • Deckow-Crist
• Clementine Bauch
nathan@yesenia.net • Romaguera-Jacobson
• Patricia Lebsack
julianne.OConner@kory.org • Robel-Corkery
• Chelsey Dietrich
lucio_Hettinger@annie.ca • Keebler LLC
[Previous] Page 1 [Next]
Khi click Next → loading → hiển thị users trang 2, v.v.
Nút Previous bị disable ở trang 1, Next disable ở trang 4 (giả định)
*/⭐⭐⭐ Level 3: Kịch Bản Thực Tế (40 phút)
/**
* 🎯 Mục tiêu: Build Image Gallery với Infinite Scroll
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là user, tôi muốn scroll xuống để load thêm ảnh"
*
* ✅ Acceptance Criteria:
* - [ ] Display 10 photos initially
* - [ ] Load more (10 photos) khi scroll đến cuối
* - [ ] Show loading indicator khi fetching
* - [ ] Handle errors gracefully
* - [ ] Prevent duplicate requests
* - [ ] Stop loading when no more photos
*
* 🎨 Technical Details:
* API: https://jsonplaceholder.typicode.com/photos?_start=${start}&_limit=10
* Total photos: 100
*
* State shape:
* {
* photos: [],
* loading: false,
* error: null,
* page: 0,
* hasMore: true
* }
*
* Actions:
* - FETCH_START
* - FETCH_SUCCESS (append photos)
* - FETCH_ERROR
* - NO_MORE_DATA
*
* 🚨 Edge Cases:
* - User scrolls nhanh → multiple requests?
* - Last page có ít hơn 10 photos
* - Network error → retry mechanism
* - Component unmount giữa request
*
* 💡 Hints:
* - Dùng useRef để track isLoading (prevent duplicate)
* - Scroll detection: window.addEventListener('scroll', ...)
* - Check scroll position: window.innerHeight + window.scrollY >= document.body.offsetHeight - 500
*
* 📝 Implementation Checklist:
* - [ ] Reducer với 4 actions
* - [ ] useEffect fetch initial data
* - [ ] useEffect setup scroll listener
* - [ ] Cleanup scroll listener
* - [ ] Prevent duplicate requests
* - [ ] Display loading indicator at bottom
* - [ ] Handle "No more data" state
*/
// TODO: Implement InfiniteScrollGallery component
// Starter:
function InfiniteScrollGallery() {
// TODO: Implement
return (
<div>
{/* Photo grid */}
{/* Loading indicator */}
{/* Error message */}
{/* "No more photos" message */}
</div>
);
}💡 Solution
/**
* InfiniteScrollGallery component
* Image gallery với infinite scroll sử dụng JSONPlaceholder photos API
* Load 10 ảnh mỗi lần, tự động fetch khi scroll gần bottom
* Xử lý loading, error, hasMore, prevent duplicate requests
* Sử dụng useReducer + useEffect + IntersectionObserver (thay vì window scroll event)
*/
import { useReducer, useEffect, useRef, useCallback } from 'react';
const PHOTOS_PER_PAGE = 10;
const API_BASE = 'https://jsonplaceholder.typicode.com/photos';
const initialState = {
photos: [],
loading: false,
error: null,
page: 0, // bắt đầu từ 0 → _start=0
hasMore: true,
};
const ActionTypes = {
FETCH_START: 'FETCH_START',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
NO_MORE_DATA: 'NO_MORE_DATA',
};
function galleryReducer(state, action) {
switch (action.type) {
case ActionTypes.FETCH_START:
return { ...state, loading: true, error: null };
case ActionTypes.FETCH_SUCCESS:
return {
...state,
photos: [...state.photos, ...action.payload],
loading: false,
page: state.page + 1,
hasMore: action.payload.length === PHOTOS_PER_PAGE,
};
case ActionTypes.FETCH_ERROR:
return { ...state, loading: false, error: action.payload };
case ActionTypes.NO_MORE_DATA:
return { ...state, hasMore: false, loading: false };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function InfiniteScrollGallery() {
const [state, dispatch] = useReducer(galleryReducer, initialState);
const observerTarget = useRef(null);
const isFetching = useRef(false);
const fetchPhotos = useCallback(
async (page, signal) => {
if (isFetching.current || !state.hasMore) return;
isFetching.current = true;
dispatch({ type: ActionTypes.FETCH_START });
try {
const start = page * PHOTOS_PER_PAGE;
const response = await fetch(
`${API_BASE}?_start=${start}&_limit=${PHOTOS_PER_PAGE}`,
{ signal },
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.length === 0) {
dispatch({ type: ActionTypes.NO_MORE_DATA });
} else {
dispatch({ type: ActionTypes.FETCH_SUCCESS, payload: data });
}
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({
type: ActionTypes.FETCH_ERROR,
payload: err.message || 'Failed to load photos',
});
}
} finally {
isFetching.current = false;
}
},
[state.hasMore],
);
// Initial fetch
useEffect(() => {
const controller = new AbortController();
fetchPhotos(0, controller.signal);
return () => controller.abort();
}, [fetchPhotos]);
// Infinite scroll với IntersectionObserver
useEffect(() => {
if (!state.hasMore || state.loading) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
const controller = new AbortController();
fetchPhotos(state.page, controller.signal);
// Cleanup cho request này
return () => controller.abort();
}
},
{ rootMargin: '200px' }, // trigger sớm hơn 200px
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [state.page, state.hasMore, state.loading, fetchPhotos]);
return (
<div>
<h3>Infinite Scroll Photo Gallery</h3>
{state.error && (
<div style={{ color: 'red', padding: '16px', background: '#ffebee' }}>
Error: {state.error}
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '16px',
padding: '16px',
}}
>
{state.photos.map((photo) => (
<div
key={photo.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<img
src={photo.thumbnailUrl}
alt={photo.title}
style={{ width: '100%', height: 'auto', display: 'block' }}
loading='lazy'
/>
<div style={{ padding: '8px', fontSize: '0.9em' }}>
{photo.title.substring(0, 60)}
{photo.title.length > 60 ? '...' : ''}
</div>
</div>
))}
</div>
{state.loading && (
<div style={{ textAlign: 'center', padding: '32px' }}>
Loading more photos...
</div>
)}
{!state.hasMore && state.photos.length > 0 && (
<div style={{ textAlign: 'center', padding: '32px', color: '#666' }}>
No more photos to load
</div>
)}
{state.photos.length === 0 && !state.loading && !state.error && (
<div style={{ textAlign: 'center', padding: '32px' }}>
No photos found
</div>
)}
{/* Sentinel element để observer theo dõi */}
<div
ref={observerTarget}
style={{ height: '20px' }}
/>
</div>
);
}
export default InfiniteScrollGallery;
/*
Kết quả ví dụ khi render:
- Ban đầu hiển thị 10 ảnh đầu tiên (id 1-10)
- Khi scroll xuống gần cuối → tự động load thêm 10 ảnh (11-20)
- Loading indicator "Loading more photos..." xuất hiện ở dưới
- Khi đạt ~100 ảnh (tổng của jsonplaceholder) → hiển thị "No more photos to load"
- Nếu có lỗi mạng → hiển thị thông báo đỏ "Error: ..."
- Ảnh dùng thumbnailUrl để load nhanh, lazy loading
*/⭐⭐⭐⭐ Level 4: Quyết Định Kiến Trúc (60 phút)
/**
* 🎯 Mục tiêu: Design Retry Logic với Exponential Backoff
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Context: API thường xuyên fail (flaky network, rate limits)
* Cần retry mechanism thông minh.
*
* Retry Strategy Options:
*
* Option A: Simple Retry (fixed delay)
* - Retry 3 lần
* - Mỗi lần cách nhau 1s
* Pros: Đơn giản
* Cons: Có thể overwhelm server
*
* Option B: Exponential Backoff
* - Retry 1: wait 1s
* - Retry 2: wait 2s
* - Retry 3: wait 4s
* Pros: Giảm server load
* Cons: Phức tạp hơn
*
* Option C: Exponential Backoff + Jitter
* - Random delay: baseDelay * 2^attempt + random(0-1000ms)
* Pros: Tránh thundering herd
* Cons: Most complex
*
* Nhiệm vụ:
* 1. So sánh 3 options
* 2. Choose best approach
* 3. Document quyết định
*
* 📝 Decision Doc:
*
* ## Context
* API có rate limit 100 req/min. Khi exceed, trả về 429.
* Cần retry để tăng success rate nhưng không spam server.
*
* ## Decision
* Chọn Option B: Exponential Backoff
*
* ## Rationale
* - Balance giữa simplicity và effectiveness
* - Giảm server load (delays tăng dần)
* - Đủ cho most use cases
* - Option C overkill cho app nhỏ
*
* ## Implementation
* - Max retries: 3
* - Base delay: 1000ms
* - Formula: delay = baseDelay * 2^attempt
*
* 💻 PHASE 2: Implementation (30 phút)
*
* State shape:
* {
* data: null,
* loading: false,
* error: null,
* retryCount: 0,
* isRetrying: false
* }
*
* Actions:
* - FETCH_START
* - FETCH_SUCCESS
* - FETCH_ERROR
* - RETRY_START
* - RETRY_FAILED (sau max retries)
*
* Requirements:
* - Tự động retry khi API fail
* - Max 3 retries
* - Exponential backoff delays
* - Show retry status: "Retrying... (attempt 2/3)"
* - Manual retry button sau max retries failed
*
* 🧪 PHASE 3: Testing (10 phút)
*
* Test cases:
* - Success on first try → no retries
* - Fail → Retry 1 (wait 1s) → Success
* - Fail → Retry 1,2,3 → Show error + manual retry button
* - Manual retry → Reset count → Start over
*
* Mock API for testing:
* - Random fail with 50% chance
* - Return 429 for rate limit
*/
// TODO: Implement với retry logic
// Starter:
const fetchWithRetry = async (url, retryCount, maxRetries, dispatch) => {
const baseDelay = 1000;
try {
// TODO: Implement fetch
// TODO: Handle retry logic
} catch (error) {
// TODO: Exponential backoff
// TODO: Dispatch retry actions
}
};💡 Solution
/**
* RetryFetchDemo component
* Implement retry logic với Exponential Backoff
* Tự động retry tối đa 3 lần khi fetch thất bại
* Hiển thị trạng thái retry (attempt X/3)
* Có nút Retry thủ công sau khi hết lượt retry
* Sử dụng random fail simulation (50% chance) để test
*/
import { useReducer, useEffect, useCallback } from 'react';
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
const initialState = {
data: null,
loading: false,
error: null,
retryCount: 0,
isRetrying: false,
};
const ActionTypes = {
FETCH_START: 'FETCH_START',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
RETRY_START: 'RETRY_START',
RESET: 'RESET',
};
function retryReducer(state, action) {
switch (action.type) {
case ActionTypes.FETCH_START:
return {
...state,
loading: true,
error: null,
isRetrying: false,
};
case ActionTypes.FETCH_SUCCESS:
return {
...state,
data: action.payload,
loading: false,
error: null,
retryCount: 0,
isRetrying: false,
};
case ActionTypes.FETCH_ERROR:
return {
...state,
loading: false,
error: action.payload,
isRetrying: false,
};
case ActionTypes.RETRY_START:
return {
...state,
loading: true,
error: null,
retryCount: state.retryCount + 1,
isRetrying: true,
};
case ActionTypes.RESET:
return { ...initialState };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function RetryFetchDemo() {
const [state, dispatch] = useReducer(retryReducer, initialState);
const fetchWithRetry = useCallback(async (attempt = 1) => {
// Reset state khi bắt đầu attempt đầu tiên
if (attempt === 1) {
dispatch({ type: ActionTypes.FETCH_START });
} else {
dispatch({ type: ActionTypes.RETRY_START });
}
try {
// Simulation API flaky (50% fail)
await new Promise((resolve) => setTimeout(resolve, 800));
// 50% chance fail
if (Math.random() < 0.5) {
throw new Error('Network error or server timeout');
}
// Giả lập data thành công
const mockData = {
id: Date.now(),
title: `Successful fetch on attempt ${attempt}`,
timestamp: new Date().toLocaleTimeString(),
};
dispatch({
type: ActionTypes.FETCH_SUCCESS,
payload: mockData,
});
} catch (err) {
const errorMessage = err.message || 'Failed to fetch data';
if (attempt >= MAX_RETRIES) {
// Hết lượt retry → show error + manual retry
dispatch({
type: ActionTypes.FETCH_ERROR,
payload: `${errorMessage} (after ${MAX_RETRIES} attempts)`,
});
} else {
// Exponential backoff
const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
// Hiển thị thông báo retry (UI sẽ cập nhật sau delay)
setTimeout(() => {
fetchWithRetry(attempt + 1);
}, delay);
}
}
}, []);
// Initial fetch khi mount
useEffect(() => {
fetchWithRetry(1);
}, [fetchWithRetry]);
const handleManualRetry = () => {
dispatch({ type: ActionTypes.RESET });
fetchWithRetry(1);
};
const getStatusMessage = () => {
if (state.loading) {
if (state.isRetrying) {
return `Retrying... (attempt ${state.retryCount}/${MAX_RETRIES})`;
}
return 'Loading...';
}
if (state.error) {
return `Error: ${state.error}`;
}
if (state.data) {
return `Success! ${state.data.title} at ${state.data.timestamp}`;
}
return 'Idle';
};
return (
<div style={{ padding: '20px', maxWidth: '500px', margin: '0 auto' }}>
<h3>Retry with Exponential Backoff Demo</h3>
<p>
API được simulate với 50% chance fail để test retry.
<br />
Base delay: 1s → 2s → 4s
</p>
<div
style={{
padding: '16px',
background: state.error
? '#ffebee'
: state.data
? '#e8f5e9'
: '#f5f5f5',
borderRadius: '8px',
minHeight: '80px',
margin: '16px 0',
}}
>
<strong>Status:</strong> {getStatusMessage()}
</div>
{state.error && (
<button
onClick={handleManualRetry}
style={{
padding: '10px 20px',
background: '#1976d2',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Retry Manually
</button>
)}
<button
onClick={handleManualRetry}
style={{
marginLeft: '12px',
padding: '10px 20px',
background: '#f57c00',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Refresh / New Attempt
</button>
{state.data && (
<div style={{ marginTop: '20px' }}>
<pre
style={{
background: '#f0f0f0',
padding: '12px',
borderRadius: '4px',
}}
>
{JSON.stringify(state.data, null, 2)}
</pre>
</div>
)}
</div>
);
}
export default RetryFetchDemo;
/*
Kết quả ví dụ khi chạy:
Trường hợp thành công ngay lần 1:
Status: Success! Successful fetch on attempt 1 at 14:35:22
Trường hợp fail → retry:
Status: Retrying... (attempt 1/3) → sau ~1s
→ Retrying... (attempt 2/3) → sau ~2s
→ Success! Successful fetch on attempt 3 at 14:35:28
Trường hợp fail hết 3 lần:
Status: Error: Network error or server timeout (after 3 attempts)
→ Nút "Retry Manually" xuất hiện
*/⭐⭐⭐⭐⭐ Level 5: Production Challenge (90 phút)
/**
* 🎯 Mục tiêu: Build Production-Ready Data Table
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
*
* "GitHub Issues Explorer" - Browse GitHub issues với advanced features
*
* Features:
* 1. Fetch issues from GitHub API
* 2. Search by title/description
* 3. Filter by state (open/closed/all)
* 4. Sort by (created, updated, comments)
* 5. Pagination (30 per page)
* 6. Loading states (initial, pagination, search)
* 7. Error handling với retry
* 8. Cache previous pages (don't re-fetch)
* 9. URL sync (filters in query params)
*
* 🏗️ Technical Design:
*
* 1. State Architecture:
* {
* // Data
* issues: [],
* cache: { 'page-1-open-created': [...], ... },
*
* // UI State
* loading: false,
* error: null,
*
* // Filters
* filters: {
* state: 'open',
* sort: 'created',
* search: '',
* page: 1
* },
*
* // Metadata
* totalCount: 0,
* hasMore: true
* }
*
* 2. Actions (15+):
* - FETCH_START
* - FETCH_SUCCESS
* - FETCH_ERROR
* - SET_FILTER
* - SET_SORT
* - SET_SEARCH
* - SET_PAGE
* - CLEAR_FILTERS
* - RETRY
* - CACHE_HIT
*
* 3. API:
* GitHub Issues API: https://api.github.com/repos/facebook/react/issues
* Query params: ?state={state}&sort={sort}&page={page}&per_page=30
*
* 4. Caching Strategy:
* - Cache key: `${page}-${state}-${sort}-${search}`
* - Max cache size: 10 pages
* - Check cache before fetch
* - LRU eviction when cache full
*
* 5. URL Sync:
* - Read initial state from URL
* - Update URL when filters change
* - Browser back/forward works
*
* 6. Performance:
* - Debounce search (500ms)
* - Cancel previous requests
* - Show cached data immediately
*
* ✅ Production Checklist:
* - [ ] Reducer handles all 15+ actions
* - [ ] useEffect fetch data on filter change
* - [ ] useEffect sync URL
* - [ ] Cache implementation (check → fetch → store)
* - [ ] Debounced search
* - [ ] AbortController cleanup
* - [ ] Loading states:
* - [ ] Initial load (skeleton)
* - [ ] Pagination (button loading)
* - [ ] Search (inline spinner)
* - [ ] Error states:
* - [ ] Network error (retry button)
* - [ ] Rate limit (wait message)
* - [ ] No results (empty state)
* - [ ] Edge cases:
* - [ ] Rapid filter changes
* - [ ] Cache hit → instant display
* - [ ] Browser back → restore from cache
* - [ ] Code quality:
* - [ ] Constants extracted
* - [ ] Helper functions (getCacheKey, etc.)
* - [ ] Comments for complex logic
*
* 📝 Documentation:
* - State shape explained
* - Caching strategy diagram
* - Action reference
*
* 🔍 Self-Review:
* - [ ] No memory leaks (cleanup all effects)
* - [ ] No race conditions (abort old requests)
* - [ ] Efficient re-renders (check deps)
* - [ ] Accessible (loading announcements, error focus)
*/
// TODO: Full implementation
// Starter: Helper functions
const getCacheKey = (filters) => {
return `${filters.page}-${filters.state}-${filters.sort}-${filters.search}`;
};
const buildApiUrl = (filters) => {
const params = new URLSearchParams({
state: filters.state,
sort: filters.sort,
page: filters.page,
per_page: 30,
});
if (filters.search) {
// GitHub search API different endpoint
return `https://api.github.com/search/issues?q=${filters.search}+repo:facebook/react&${params}`;
}
return `https://api.github.com/repos/facebook/react/issues?${params}`;
};
// TODO: Implement GitHubIssuesExplorer component💡 Solution
/**
* GitHubIssuesExplorer - Production-ready issues browser
* Features:
* - Fetch issues from facebook/react repo
* - Search (debounced), filter by state, sort, pagination
* - Loading states (initial + pagination + search)
* - Error handling with retry
* - Simple in-memory cache (max 10 entries)
* - URL sync using URLSearchParams + history.pushState
* - AbortController to cancel outdated requests
*/
import { useReducer, useEffect, useRef, useCallback } from 'react';
const PER_PAGE = 30;
const CACHE_MAX_SIZE = 10;
const DEBOUNCE_DELAY = 500;
const initialState = {
issues: [],
cache: {}, // key → {issues, totalCount}
loading: false,
error: null,
filters: {
state: 'open',
sort: 'created',
search: '',
page: 1,
},
totalCount: 0,
hasMore: true,
};
const ActionTypes = {
FETCH_START: 'FETCH_START',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
SET_FILTER: 'SET_FILTER',
SET_PAGE: 'SET_PAGE',
CACHE_HIT: 'CACHE_HIT',
RETRY: 'RETRY',
CLEAR_ERROR: 'CLEAR_ERROR',
};
function issuesReducer(state, action) {
switch (action.type) {
case ActionTypes.FETCH_START:
return { ...state, loading: true, error: null };
case ActionTypes.FETCH_SUCCESS:
return {
...state,
issues: action.payload.issues,
totalCount: action.payload.totalCount,
hasMore: action.payload.issues.length === PER_PAGE,
cache: {
...state.cache,
[action.payload.cacheKey]: {
issues: action.payload.issues,
totalCount: action.payload.totalCount,
},
},
loading: false,
error: null,
};
case ActionTypes.CACHE_HIT:
return {
...state,
issues: action.payload.issues,
totalCount: action.payload.totalCount,
hasMore: action.payload.issues.length === PER_PAGE,
loading: false,
error: null,
};
case ActionTypes.FETCH_ERROR:
return { ...state, loading: false, error: action.payload };
case ActionTypes.SET_FILTER:
return {
...state,
filters: { ...state.filters, ...action.payload, page: 1 },
};
case ActionTypes.SET_PAGE:
return {
...state,
filters: { ...state.filters, page: action.payload },
};
case ActionTypes.RETRY:
return { ...state, error: null };
case ActionTypes.CLEAR_ERROR:
return { ...state, error: null };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function getCacheKey(filters) {
return `${filters.page}-${filters.state}-${filters.sort}-${filters.search.trim().toLowerCase()}`;
}
function buildApiUrl(filters) {
const params = new URLSearchParams({
state: filters.state,
sort: filters.sort,
direction: 'desc',
per_page: PER_PAGE,
page: filters.page,
});
let url = `https://api.github.com/repos/facebook/react/issues?${params}`;
if (filters.search.trim()) {
// Switch to search endpoint when there's a query
const q = `${filters.search} repo:facebook/react type:issue state:${filters.state}`;
url = `https://api.github.com/search/issues?q=${encodeURIComponent(q)}&sort=${filters.sort}&order=desc&page=${filters.page}&per_page=${PER_PAGE}`;
}
return url;
}
function GitHubIssuesExplorer() {
const [state, dispatch] = useReducer(issuesReducer, initialState);
const abortControllerRef = useRef(null);
const debounceTimerRef = useRef(null);
const isInitialMount = useRef(true);
// Read initial filters from URL
useEffect(() => {
if (isInitialMount.current) {
const params = new URLSearchParams(window.location.search);
const initialFilters = {
state: params.get('state') || 'open',
sort: params.get('sort') || 'created',
search: params.get('search') || '',
page: Number(params.get('page')) || 1,
};
dispatch({ type: ActionTypes.SET_FILTER, payload: initialFilters });
isInitialMount.current = false;
}
}, []);
// Sync filters → URL
const syncUrl = useCallback(() => {
const params = new URLSearchParams();
if (state.filters.state !== 'open')
params.set('state', state.filters.state);
if (state.filters.sort !== 'created')
params.set('sort', state.filters.sort);
if (state.filters.search) params.set('search', state.filters.search);
if (state.filters.page !== 1) params.set('page', state.filters.page);
const query = params.toString();
const newUrl = query ? `?${query}` : window.location.pathname;
window.history.replaceState(null, '', newUrl);
}, [state.filters]);
useEffect(() => {
if (!isInitialMount.current) {
syncUrl();
}
}, [state.filters, syncUrl]);
const fetchIssues = useCallback(async () => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
const cacheKey = getCacheKey(state.filters);
// Check cache first
if (state.cache[cacheKey]) {
dispatch({
type: ActionTypes.CACHE_HIT,
payload: {
issues: state.cache[cacheKey].issues,
totalCount: state.cache[cacheKey].totalCount,
},
});
return;
}
dispatch({ type: ActionTypes.FETCH_START });
try {
const url = buildApiUrl(state.filters);
const response = await fetch(url, { signal });
if (!response.ok) {
if (response.status === 403 || response.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.');
}
throw new Error(`GitHub API error: ${response.status}`);
}
let issues = [];
let totalCount = 0;
if (state.filters.search.trim()) {
// Search endpoint returns different shape
const data = await response.json();
issues = data.items || [];
totalCount = data.total_count || 0;
} else {
issues = await response.json();
// For regular issues endpoint, total count from Link header or assume hasMore
const linkHeader = response.headers.get('Link');
totalCount = linkHeader?.includes('rel="last"') ? 9999 : issues.length; // approximation
}
// Limit cache size (simple LRU-like)
const newCache = { ...state.cache };
if (Object.keys(newCache).length >= CACHE_MAX_SIZE) {
const oldestKey = Object.keys(newCache)[0];
delete newCache[oldestKey];
}
dispatch({
type: ActionTypes.FETCH_SUCCESS,
payload: {
issues,
totalCount,
cacheKey,
},
});
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({
type: ActionTypes.FETCH_ERROR,
payload: err.message || 'Failed to load issues',
});
}
}
}, [state.filters, state.cache]);
// Debounced fetch for search + immediate for other filters/page
useEffect(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Search → debounce
if (state.filters.search !== initialState.filters.search) {
debounceTimerRef.current = setTimeout(() => {
fetchIssues();
}, DEBOUNCE_DELAY);
} else {
// Page / state / sort change → immediate
fetchIssues();
}
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [state.filters, fetchIssues]);
const handleFilterChange = (key, value) => {
dispatch({
type: ActionTypes.SET_FILTER,
payload: { [key]: value },
});
};
const handlePageChange = (newPage) => {
if (
newPage < 1 ||
(newPage > Math.ceil(state.totalCount / PER_PAGE) && state.totalCount > 0)
)
return;
dispatch({ type: ActionTypes.SET_PAGE, payload: newPage });
};
const handleRetry = () => {
dispatch({ type: ActionTypes.RETRY });
fetchIssues();
};
const totalPages =
state.totalCount > 0 ? Math.ceil(state.totalCount / PER_PAGE) : 1;
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<h2>React Repository Issues Explorer</h2>
{/* Controls */}
<div
style={{
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
marginBottom: '24px',
}}
>
<div>
<label>State: </label>
<select
value={state.filters.state}
onChange={(e) => handleFilterChange('state', e.target.value)}
>
<option value='open'>Open</option>
<option value='closed'>Closed</option>
<option value='all'>All</option>
</select>
</div>
<div>
<label>Sort: </label>
<select
value={state.filters.sort}
onChange={(e) => handleFilterChange('sort', e.target.value)}
>
<option value='created'>Created</option>
<option value='updated'>Updated</option>
<option value='comments'>Comments</option>
</select>
</div>
<div style={{ flex: '1', minWidth: '200px' }}>
<input
type='text'
placeholder='Search issues...'
value={state.filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
style={{ width: '100%', padding: '8px' }}
/>
</div>
</div>
{/* Loading / Error */}
{state.loading && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
Loading issues...
</div>
)}
{state.error && (
<div
style={{
padding: '16px',
background: '#ffebee',
color: '#c62828',
borderRadius: '8px',
marginBottom: '16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>Error: {state.error}</span>
<button
onClick={handleRetry}
style={{
padding: '8px 16px',
background: '#c62828',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Retry
</button>
</div>
)}
{/* Issues List */}
{!state.loading && !state.error && (
<>
{state.issues.length === 0 ? (
<div
style={{ textAlign: 'center', padding: '40px', color: '#666' }}
>
No issues found matching your criteria
</div>
) : (
<>
<div style={{ marginBottom: '16px' }}>
Showing {state.issues.length} of ~{state.totalCount} issues
</div>
<ul style={{ listStyle: 'none', padding: 0 }}>
{state.issues.map((issue) => (
<li
key={issue.id}
style={{
padding: '16px',
borderBottom: '1px solid #eee',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<span
style={{
fontWeight: 'bold',
color: issue.state === 'open' ? '#2e7d32' : '#d32f2f',
}}
>
#{issue.number}
</span>
<a
href={issue.html_url}
target='_blank'
rel='noopener noreferrer'
style={{
fontSize: '1.1em',
textDecoration: 'none',
color: '#1976d2',
}}
>
{issue.title}
</a>
</div>
<div style={{ color: '#555', fontSize: '0.95em' }}>
Opened by <strong>{issue.user.login}</strong> •{' '}
{new Date(issue.created_at).toLocaleDateString()} •{' '}
{issue.comments} comments
</div>
{issue.labels.length > 0 && (
<div
style={{
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
{issue.labels.map((label) => (
<span
key={label.id}
style={{
background: `#${label.color}22`,
color: '#000',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '0.85em',
border: `1px solid #${label.color}`,
}}
>
{label.name}
</span>
))}
</div>
)}
</li>
))}
</ul>
{/* Pagination */}
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '16px',
marginTop: '32px',
alignItems: 'center',
}}
>
<button
onClick={() => handlePageChange(state.filters.page - 1)}
disabled={state.filters.page === 1 || state.loading}
style={{ padding: '8px 16px' }}
>
Previous
</button>
<span>
Page {state.filters.page}{' '}
{state.totalCount > 0
? `of ~${Math.ceil(state.totalCount / PER_PAGE)}`
: ''}
</span>
<button
onClick={() => handlePageChange(state.filters.page + 1)}
disabled={!state.hasMore || state.loading}
style={{ padding: '8px 16px' }}
>
Next
</button>
</div>
</>
)}
</>
)}
</div>
);
}
export default GitHubIssuesExplorer;
/*
Kết quả ví dụ khi chạy:
- Mở component → load open issues, sort by created, page 1
- URL: ?state=open&sort=created&page=1 (nếu thay đổi)
- Gõ search "useEffect" → sau 500ms load kết quả search
- Chuyển sang Closed → load closed issues
- Click Next → page 2, cache trang 1 vẫn giữ
- Nếu rate limit hoặc mạng lỗi → hiển thị error + nút Retry
- Quay lại page 1 bằng nút Previous hoặc browser back → lấy từ cache (instant)
*/📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: Async State Management Patterns
| Pattern | Code Complexity | Performance | Use Case | Winner |
|---|---|---|---|---|
| Multiple useState | ✅ Simple | ❌ Có thể inconsistent | Simple fetches | Small apps |
| useReducer | ⚠️ Medium | ✅ Consistent state | Complex async flows | Medium-Large apps |
| useReducer + Cache | ❌ Complex | ✅✅ Very fast | Repeated fetches | High-traffic features |
| Optimistic Updates | ❌ Complex | ✅✅ Feels instant | User interactions | Social apps |
Decision Tree: Async Pattern Selection
START: Cần fetch data?
│
├─ Simple one-time fetch?
│ └─ YES → useState + useEffect ✅
│ Example: Fetch user profile once
│
├─ Multiple related async states?
│ └─ YES → useReducer + useEffect ✅
│ Example: loading, data, error, retryCount
│
├─ Need retry logic?
│ └─ YES → useReducer với retry actions ✅
│ Actions: FETCH_START, RETRY_START, MAX_RETRIES_REACHED
│
├─ Filters/Pagination (repeated fetches)?
│ └─ YES → useReducer + Caching ✅
│ Cache previous pages/filters
│
├─ User mutations (create/update/delete)?
│ └─ YES
│ │
│ ├─ Can tolerate delay?
│ │ └─ Normal pattern (wait for API)
│ │
│ └─ Need instant feedback?
│ └─ Optimistic Updates ✅
│
├─ Real-time updates (WebSocket, polling)?
│ └─ YES → useReducer + useEffect subscription
│ Actions: CONNECTED, DISCONNECTED, MESSAGE_RECEIVED
│
└─ Complex workflows (multi-step forms, wizards)?
└─ useReducer state machine + async actionsLoading States Best Practices
❌ Bad: Boolean loading
const [loading, setLoading] = useState(false);
// Problem: Can't distinguish initial vs refresh✅ Good: State machine
const states = {
idle: 'idle',
loading: 'loading',
success: 'success',
error: 'error',
};
// Can show different UI for each state✅ Better: Multiple loading states
{
initialLoading: false, // First load → skeleton
refreshing: false, // Pull to refresh → inline spinner
loadingMore: false, // Pagination → button spinner
}Error Handling Strategies
| Strategy | When to Use | Example |
|---|---|---|
| Show error + retry | Recoverable errors | Network timeout → "Retry" button |
| Silent retry | Transient errors | 429 rate limit → auto retry with backoff |
| Fallback UI | Non-critical features | Image load fail → placeholder |
| Error boundary | Critical errors | Component crash → error page |
| Toast notification | Background operations | "Failed to save draft" toast |
🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Race Condition 🐛
// ❌ CODE BỊ LỖI
function UserSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
const fetchResults = async () => {
const response = await fetch(`/api/search?q=${searchTerm}`);
const data = await response.json();
setResults(data); // 🐛 Always sets results, even if outdated!
};
if (searchTerm) {
fetchResults();
}
}, [searchTerm]);
// Scenario:
// 1. User types "react"
// 2. Request A starts (takes 2s)
// 3. User types "reactjs"
// 4. Request B starts (takes 0.5s)
// 5. Request B completes → setResults(results for "reactjs") ✅
// 6. Request A completes → setResults(results for "react") ❌
// Result: UI shows results for "react" but search term is "reactjs"!
}❓ Câu hỏi:
- Vấn đề gì với code?
- Khi nào bug xảy ra?
- Làm sao fix?
💡 Giải thích:
- Multiple requests in-flight
- Slower request completes last → overwrites newer results
- User sees wrong data!
✅ Fix:
// ✅ SOLUTION 1: AbortController
function UserSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
const fetchResults = async () => {
try {
const response = await fetch(`/api/search?q=${searchTerm}`, {
signal: controller.signal, // ✅ Attach signal
});
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
};
if (searchTerm) {
fetchResults();
}
return () => {
controller.abort(); // ✅ Cancel previous request
};
}, [searchTerm]);
}
// ✅ SOLUTION 2: Ignore stale results
function UserSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
let isCurrentRequest = true; // ✅ Flag
const fetchResults = async () => {
const response = await fetch(`/api/search?q=${searchTerm}`);
const data = await response.json();
if (isCurrentRequest) {
// ✅ Only set if still current
setResults(data);
}
};
if (searchTerm) {
fetchResults();
}
return () => {
isCurrentRequest = false; // ✅ Mark as stale
};
}, [searchTerm]);
}Bug 2: Infinite Loop với Dispatch 🐛
// ❌ CODE BỊ LỖI
function TodoList() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const fetchTodos = async () => {
const response = await fetch('/api/todos');
const data = await response.json();
dispatch({ type: 'SET_TODOS', payload: data });
};
fetchTodos();
}, [dispatch]); // 🐛 dispatch in dependencies!
// Result: Infinite loop!
// dispatch stable? Vậy tại sao loop?
}❓ Câu hỏi:
- Code có vẻ đúng, tại sao infinite loop?
- dispatch có stable không?
- Làm sao fix?
💡 Giải thích:
dispatchIS stable (React guarantees)- BUG thực sự: Linter yêu cầu include
dispatch(exhaustive-deps) - Nhưng dispatch stable → không gây re-run
- Thực tế không loop! Đây là trick question 😄
- Tuy nhiên best practice: Không cần dispatch trong deps
✅ Fix:
// ✅ Cách 1: Bỏ dispatch khỏi deps (safe vì stable)
useEffect(() => {
const fetchTodos = async () => {
const response = await fetch('/api/todos');
const data = await response.json();
dispatch({ type: 'SET_TODOS', payload: data });
};
fetchTodos();
}, []); // Empty deps OK
// ✅ Cách 2: Disable linter cho dòng này
useEffect(() => {
const fetchTodos = async () => {
const response = await fetch('/api/todos');
const data = await response.json();
dispatch({ type: 'SET_TODOS', payload: data });
};
fetchTodos();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);Bug 3: Forgotten Cleanup 🐛
// ❌ CODE BỊ LỖI
function CountdownTimer({ duration }) {
const [state, dispatch] = useReducer(timerReducer, { seconds: duration });
useEffect(() => {
dispatch({ type: 'START' });
const interval = setInterval(() => {
dispatch({ type: 'TICK' });
}, 1000);
// 🐛 No cleanup!
// Khi component unmount → interval vẫn chạy!
}, []);
// Memory leak: interval continues, dispatch sau unmount → warning
}❓ Câu hỏi:
- Vấn đề gì xảy ra khi component unmount?
- Console warning gì?
- Làm sao fix?
💡 Giải thích:
- setInterval continues after unmount
- dispatch vào unmounted component → React warning
- Memory leak (interval không clear)
✅ Fix:
function CountdownTimer({ duration }) {
const [state, dispatch] = useReducer(timerReducer, { seconds: duration });
useEffect(() => {
dispatch({ type: 'START' });
const interval = setInterval(() => {
dispatch({ type: 'TICK' });
}, 1000);
// ✅ Cleanup interval
return () => {
clearInterval(interval);
};
}, []);
}✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
Đánh dấu ✅ khi bạn tự tin:
Async Basics:
- [ ] Tôi hiểu reducer phải synchronous
- [ ] Tôi biết cách dispatch trong useEffect
- [ ] Tôi hiểu flow: effect → dispatch → reducer → re-render
- [ ] Tôi biết 3 action pattern: START, SUCCESS, ERROR
Request Management:
- [ ] Tôi biết cách dùng AbortController
- [ ] Tôi hiểu race condition là gì
- [ ] Tôi biết khi nào cần cleanup
- [ ] Tôi biết cách ignore stale results
Advanced Patterns:
- [ ] Tôi hiểu optimistic updates pattern
- [ ] Tôi biết cách implement retry logic
- [ ] Tôi biết cách debounce requests
- [ ] Tôi hiểu caching strategy
Error Handling:
- [ ] Tôi biết cách handle network errors
- [ ] Tôi biết cách check AbortError
- [ ] Tôi biết cách implement retry
- [ ] Tôi biết cách rollback optimistic updates
Code Review Checklist
useEffect:
- [ ] Có cleanup function (AbortController, clear timers)
- [ ] Dependencies chính xác
- [ ] Không dispatch nếu request aborted
Reducer:
- [ ] Có actions cho START, SUCCESS, ERROR
- [ ] State transitions rõ ràng
- [ ] Immutable updates
- [ ] Default case
Error Handling:
- [ ] Try-catch cho async operations
- [ ] Check
error.name !== 'AbortError' - [ ] User-friendly error messages
- [ ] Retry options nếu recoverable
Performance:
- [ ] Debounce search inputs
- [ ] Cancel previous requests
- [ ] Cache when appropriate
- [ ] Prevent duplicate requests
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Bài 1: Implement Auto-Save
Requirements:
- Form với multiple fields (title, description, tags)
- Auto-save 2 giây sau khi user ngừng typing
- Show save status: "Saved", "Saving...", "Error"
- Retry nếu save fails
- Don't save nếu no changes
State shape:
{
formData: { title: '', description: '', tags: [] },
saveStatus: 'idle', // 'idle' | 'saving' | 'saved' | 'error'
lastSaved: null, // timestamp
hasUnsavedChanges: false
}Actions:
- UPDATE_FIELD
- SAVE_START
- SAVE_SUCCESS
- SAVE_ERROR
- RESET_CHANGES
💡 Solution
/**
* AutoSaveForm component
* Form với auto-save sau 2 giây khi user ngừng typing
* Hiển thị trạng thái: idle, saving, saved, error
* Chỉ save khi có thay đổi (hasUnsavedChanges)
* Có retry khi save thất bại
* Sử dụng useReducer để quản lý form state & save state
*/
import { useReducer, useEffect, useRef } from 'react';
const AUTO_SAVE_DELAY = 2000; // 2 giây
const MAX_RETRY = 2;
const initialState = {
formData: {
title: '',
description: '',
tags: '',
},
saveStatus: 'idle', // 'idle' | 'saving' | 'saved' | 'error'
lastSaved: null,
hasUnsavedChanges: false,
retryCount: 0,
errorMessage: null,
};
const ActionTypes = {
UPDATE_FIELD: 'UPDATE_FIELD',
SAVE_START: 'SAVE_START',
SAVE_SUCCESS: 'SAVE_SUCCESS',
SAVE_ERROR: 'SAVE_ERROR',
RESET_CHANGES: 'RESET_CHANGES',
RETRY: 'RETRY',
};
function formReducer(state, action) {
switch (action.type) {
case ActionTypes.UPDATE_FIELD:
return {
...state,
formData: {
...state.formData,
[action.payload.field]: action.payload.value,
},
hasUnsavedChanges: true,
saveStatus: 'idle',
errorMessage: null,
};
case ActionTypes.SAVE_START:
return {
...state,
saveStatus: 'saving',
errorMessage: null,
retryCount: action.payload?.isRetry ? state.retryCount : 0,
};
case ActionTypes.SAVE_SUCCESS:
return {
...state,
saveStatus: 'saved',
lastSaved: Date.now(),
hasUnsavedChanges: false,
retryCount: 0,
errorMessage: null,
};
case ActionTypes.SAVE_ERROR:
return {
...state,
saveStatus: 'error',
errorMessage: action.payload,
retryCount: state.retryCount + 1,
};
case ActionTypes.RESET_CHANGES:
return {
...state,
hasUnsavedChanges: false,
saveStatus: 'idle',
};
case ActionTypes.RETRY:
return {
...state,
saveStatus: 'saving',
errorMessage: null,
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function AutoSaveForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const debounceTimerRef = useRef(null);
const abortControllerRef = useRef(null);
// Cleanup khi unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
// Auto-save logic
useEffect(() => {
if (!state.hasUnsavedChanges || state.saveStatus === 'saving') {
return;
}
// Clear timer cũ nếu có
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
saveFormData();
}, AUTO_SAVE_DELAY);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [state.formData, state.hasUnsavedChanges, state.saveStatus]);
const saveFormData = async (isRetry = false) => {
// Cancel request cũ nếu có
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
dispatch({ type: ActionTypes.SAVE_START, payload: { isRetry } });
try {
// Simulate API call (thay bằng fetch thật trong production)
await new Promise((resolve, reject) => {
setTimeout(() => {
// 20% chance fail để test retry
if (Math.random() < 0.2 && !isRetry) {
reject(new Error('Server timeout'));
} else {
resolve();
}
}, 1200);
});
console.log('Saved:', state.formData);
dispatch({ type: ActionTypes.SAVE_SUCCESS });
} catch (err) {
if (err.name === 'AbortError') return;
const message = err.message || 'Failed to save';
if (state.retryCount < MAX_RETRY) {
// Tự động retry sau 2s
setTimeout(() => {
saveFormData(true);
}, 2000);
}
dispatch({
type: ActionTypes.SAVE_ERROR,
payload: message,
});
}
};
const handleChange = (field, value) => {
dispatch({
type: ActionTypes.UPDATE_FIELD,
payload: { field, value },
});
};
const handleManualRetry = () => {
dispatch({ type: ActionTypes.RETRY });
saveFormData(true);
};
const getStatusDisplay = () => {
switch (state.saveStatus) {
case 'saving':
return 'Saving...';
case 'saved':
return state.lastSaved
? `Saved at ${new Date(state.lastSaved).toLocaleTimeString()}`
: 'Saved';
case 'error':
return `Error: ${state.errorMessage}`;
default:
return state.hasUnsavedChanges ? 'Unsaved changes' : 'All saved';
}
};
return (
<div style={{ maxWidth: '600px', margin: '40px auto', padding: '20px' }}>
<h2>Auto-Save Form</h2>
<div
style={{
marginBottom: '20px',
padding: '12px',
background:
state.saveStatus === 'saving'
? '#fff3e0'
: state.saveStatus === 'saved'
? '#e8f5e9'
: state.saveStatus === 'error'
? '#ffebee'
: '#f5f5f5',
borderRadius: '8px',
fontWeight: state.hasUnsavedChanges ? 'bold' : 'normal',
}}
>
{getStatusDisplay()}
{state.saveStatus === 'error' && state.retryCount <= MAX_RETRY && (
<span>
{' '}
(Retrying {state.retryCount}/{MAX_RETRY})
</span>
)}
</div>
<div style={{ marginBottom: '16px' }}>
<label>Title</label>
<input
type='text'
value={state.formData.title}
onChange={(e) => handleChange('title', e.target.value)}
placeholder='Enter title...'
style={{ width: '100%', padding: '10px', marginTop: '6px' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label>Description</label>
<textarea
value={state.formData.description}
onChange={(e) => handleChange('description', e.target.value)}
placeholder='Enter description...'
rows={4}
style={{ width: '100%', padding: '10px', marginTop: '6px' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label>Tags (comma separated)</label>
<input
type='text'
value={state.formData.tags}
onChange={(e) => handleChange('tags', e.target.value)}
placeholder='react, javascript, hooks...'
style={{ width: '100%', padding: '10px', marginTop: '6px' }}
/>
</div>
{state.saveStatus === 'error' && (
<button
onClick={handleManualRetry}
style={{
padding: '10px 20px',
background: '#d32f2f',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginTop: '12px',
}}
>
Retry Save
</button>
)}
</div>
);
}
export default AutoSaveForm;
/*
Kết quả ví dụ khi sử dụng:
- Gõ title → sau 2 giây: "Saving..." → "Saved at 14:35:22"
- Gõ tiếp description → "Unsaved changes" → sau 2s: "Saving..." → "Saved at 14:35:28"
- Nếu API fail (simulate 20%): "Error: Server timeout (Retrying 1/2)" → tự retry
- Sau max retry vẫn fail → hiển thị error + nút "Retry Save"
- Tags được lưu dưới dạng string (có thể split sau)
*/Nâng cao (60 phút)
Bài 2: Build Real-time Chat với Polling
Requirements:
- Fetch messages mỗi 5 giây (polling)
- Display messages with timestamps
- Send message (optimistic update)
- Stop polling khi user inactive (5 phút)
- Resume polling khi user active
- Handle errors gracefully
State shape:
{
messages: [],
loading: false,
error: null,
isPolling: true,
lastActivity: Date.now()
}Actions:
- FETCH_MESSAGES_SUCCESS
- SEND_MESSAGE_OPTIMISTIC
- SEND_MESSAGE_CONFIRMED
- SEND_MESSAGE_FAILED
- START_POLLING
- STOP_POLLING
- UPDATE_ACTIVITY
Challenges:
- Detect user activity (mouse move, keypress)
- Clear polling interval on inactivity
- Resume on activity
- Don't duplicate messages (check message IDs)
💡 Solution
/**
* RealTimeChat component
* Real-time chat với polling (fetch messages mỗi 5s)
* Send message với optimistic update
* Stop polling sau 5 phút inactive, resume khi active
* Detect activity: mouse move, key press
* Handle errors, avoid duplicate messages (by id)
* Sử dụng mock API (local state hoặc JSONPlaceholder simulate)
*/
import { useReducer, useEffect, useRef, useState } from 'react';
// Mock API (simulate server với localStorage để persist)
const MOCK_SERVER_DELAY = 1000;
const POLLING_INTERVAL = 5000; // 5s
const INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5 phút
let mockMessages = JSON.parse(localStorage.getItem('mockChatMessages')) || [];
const mockFetchMessages = async () => {
await new Promise((resolve) => setTimeout(resolve, MOCK_SERVER_DELAY));
return [...mockMessages];
};
const mockSendMessage = async (newMessage) => {
await new Promise((resolve) => setTimeout(resolve, MOCK_SERVER_DELAY));
const sentMessage = {
...newMessage,
id: Date.now().toString(), // Real ID from server
timestamp: Date.now(),
};
mockMessages.push(sentMessage);
localStorage.setItem('mockChatMessages', JSON.stringify(mockMessages));
return sentMessage;
};
const initialState = {
messages: [],
loading: false,
error: null,
isPolling: true,
lastActivity: Date.now(),
};
const ActionTypes = {
FETCH_MESSAGES_SUCCESS: 'FETCH_MESSAGES_SUCCESS',
SEND_MESSAGE_OPTIMISTIC: 'SEND_MESSAGE_OPTIMISTIC',
SEND_MESSAGE_CONFIRMED: 'SEND_MESSAGE_CONFIRMED',
SEND_MESSAGE_FAILED: 'SEND_MESSAGE_FAILED',
START_POLLING: 'START_POLLING',
STOP_POLLING: 'STOP_POLLING',
UPDATE_ACTIVITY: 'UPDATE_ACTIVITY',
SET_ERROR: 'SET_ERROR',
CLEAR_ERROR: 'CLEAR_ERROR',
};
function chatReducer(state, action) {
switch (action.type) {
case ActionTypes.FETCH_MESSAGES_SUCCESS:
// Merge messages, tránh duplicate bằng id
const newMessages = action.payload;
const messageMap = new Map(state.messages.map((msg) => [msg.id, msg]));
newMessages.forEach((msg) => {
if (!messageMap.has(msg.id)) {
messageMap.set(msg.id, msg);
}
});
return {
...state,
messages: Array.from(messageMap.values()).sort(
(a, b) => a.timestamp - b.timestamp,
),
loading: false,
error: null,
};
case ActionTypes.SEND_MESSAGE_OPTIMISTIC:
return {
...state,
messages: [
...state.messages,
{
...action.payload,
id: `temp-${Date.now()}`,
isOptimistic: true,
},
],
};
case ActionTypes.SEND_MESSAGE_CONFIRMED:
return {
...state,
messages: state.messages.map((msg) =>
msg.id === action.payload.tempId
? { ...action.payload.message, isOptimistic: false }
: msg,
),
};
case ActionTypes.SEND_MESSAGE_FAILED:
return {
...state,
messages: state.messages.filter(
(msg) => msg.id !== action.payload.tempId,
),
error: action.payload.error,
};
case ActionTypes.START_POLLING:
return {
...state,
isPolling: true,
};
case ActionTypes.STOP_POLLING:
return {
...state,
isPolling: false,
};
case ActionTypes.UPDATE_ACTIVITY:
return {
...state,
lastActivity: Date.now(),
isPolling: true,
};
case ActionTypes.SET_ERROR:
return {
...state,
error: action.payload,
loading: false,
};
case ActionTypes.CLEAR_ERROR:
return {
...state,
error: null,
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function RealTimeChat() {
const [state, dispatch] = useReducer(chatReducer, initialState);
const [inputMessage, setInputMessage] = useState('');
const pollingIntervalRef = useRef(null);
const inactivityTimeoutRef = useRef(null);
const abortControllerRef = useRef(null);
// Fetch messages
const fetchMessages = async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const messages = await mockFetchMessages();
dispatch({ type: ActionTypes.FETCH_MESSAGES_SUCCESS, payload: messages });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({
type: ActionTypes.SET_ERROR,
payload: err.message || 'Failed to fetch messages',
});
}
}
};
// Polling setup
useEffect(() => {
if (state.isPolling) {
fetchMessages(); // Initial fetch
pollingIntervalRef.current = setInterval(fetchMessages, POLLING_INTERVAL);
} else {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
}
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [state.isPolling]);
// Inactivity detection
useEffect(() => {
const checkInactivity = () => {
const timeSinceLastActivity = Date.now() - state.lastActivity;
if (timeSinceLastActivity >= INACTIVITY_TIMEOUT && state.isPolling) {
dispatch({ type: ActionTypes.STOP_POLLING });
}
};
inactivityTimeoutRef.current = setInterval(checkInactivity, 1000); // Check mỗi giây
return () => {
if (inactivityTimeoutRef.current) {
clearInterval(inactivityTimeoutRef.current);
}
};
}, [state.lastActivity, state.isPolling]);
// Detect user activity
useEffect(() => {
const handleActivity = () => {
dispatch({ type: ActionTypes.UPDATE_ACTIVITY });
};
window.addEventListener('mousemove', handleActivity);
window.addEventListener('keypress', handleActivity);
return () => {
window.removeEventListener('mousemove', handleActivity);
window.removeEventListener('keypress', handleActivity);
};
}, []);
// Send message
const handleSendMessage = async () => {
if (!inputMessage.trim()) return;
const tempId = `temp-${Date.now()}`;
const optimisticMessage = {
content: inputMessage,
timestamp: Date.now(),
id: tempId,
};
dispatch({
type: ActionTypes.SEND_MESSAGE_OPTIMISTIC,
payload: optimisticMessage,
});
setInputMessage('');
try {
const confirmedMessage = await mockSendMessage({
content: inputMessage,
timestamp: Date.now(),
});
dispatch({
type: ActionTypes.SEND_MESSAGE_CONFIRMED,
payload: { tempId, message: confirmedMessage },
});
} catch (err) {
dispatch({
type: ActionTypes.SEND_MESSAGE_FAILED,
payload: { tempId, error: err.message || 'Failed to send message' },
});
}
};
return (
<div
style={{
maxWidth: '600px',
margin: '40px auto',
padding: '20px',
border: '1px solid #ddd',
borderRadius: '8px',
}}
>
<h2>Real-Time Chat</h2>
<div
style={{
marginBottom: '16px',
color: state.isPolling ? 'green' : 'orange',
}}
>
Status:{' '}
{state.isPolling ? 'Polling active' : 'Polling paused (inactive)'}
</div>
{state.error && (
<div
style={{
padding: '12px',
background: '#ffebee',
color: '#c62828',
borderRadius: '8px',
marginBottom: '16px',
}}
>
Error: {state.error}
<button
onClick={() => {
dispatch({ type: ActionTypes.CLEAR_ERROR });
fetchMessages();
}}
style={{ marginLeft: '12px', padding: '4px 8px' }}
>
Retry
</button>
</div>
)}
<div
style={{
height: '300px',
overflowY: 'auto',
border: '1px solid #eee',
padding: '12px',
marginBottom: '16px',
borderRadius: '4px',
}}
>
{state.messages.length === 0 && !state.loading && (
<div style={{ textAlign: 'center', color: '#888' }}>
No messages yet
</div>
)}
{state.messages.map((msg) => (
<div
key={msg.id}
style={{
marginBottom: '12px',
opacity: msg.isOptimistic ? 0.6 : 1,
fontStyle: msg.isOptimistic ? 'italic' : 'normal',
}}
>
<strong>{new Date(msg.timestamp).toLocaleTimeString()}</strong>:{' '}
{msg.content}
{msg.isOptimistic && <span> (sending...)</span>}
</div>
))}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type='text'
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder='Type a message...'
style={{ flex: 1, padding: '10px' }}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSendMessage();
}
}}
/>
<button
onClick={handleSendMessage}
style={{
padding: '10px 20px',
background: '#1976d2',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
Send
</button>
</div>
</div>
);
}
export default RealTimeChat;
/*
Kết quả ví dụ khi sử dụng:
- Mount → fetch initial messages → display with timestamps
- Send message → optimistic add (opacity 0.6, "sending...") → confirmed (opacity 1)
- Mỗi 5s → poll new messages, merge without duplicates
- Không active 5 phút (no mouse/key) → stop polling
- Mouse move hoặc keypress → resume polling
- Error → hiển thị message + Retry button
- Messages persist qua localStorage (mock server)
*/📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Docs - useEffect:
- https://react.dev/reference/react/useEffect
- Phần "Fetching data with Effects"
AbortController:
- https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- Essential cho request cancellation
Đọc thêm
Race Conditions in React:
- https://maxrozen.com/race-conditions-fetching-data-react-with-useeffect
- Real-world examples
Optimistic UI Updates:
- https://www.apollographql.com/docs/react/performance/optimistic-ui/
- Pattern từ Apollo (áp dụng cho fetch)
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (Đã học)
Ngày 16-20: useEffect
- Hôm nay apply cho async operations
- Dependencies, cleanup critical!
Ngày 21-22: useRef
- Track previous values
- Flags for ignoring stale results
Ngày 26-27: useReducer
- Foundation cho async state management
- Actions cho state transitions
Hướng tới (Sẽ học)
Ngày 29: Custom Hooks
- useFetch, useAsync hooks
- Reusable async logic
- Encapsulate patterns from today
Ngày 31-34: Performance
- useMemo for expensive computations
- useCallback for stable callbacks
- Optimize re-renders
Phase 5: Context
- Global async state
- Context + useReducer pattern
- Share loading states
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. Request Priority:
// ✅ Cancel low-priority requests for high-priority
useEffect(() => {
const controller = new AbortController();
// Low priority: analytics
fetch('/api/analytics', { signal: controller.signal });
return () => controller.abort(); // Cancel if user navigates
}, []);2. Error Categorization:
const handleError = (error) => {
if (error.status === 429) {
// Rate limit → retry with backoff
dispatch({ type: 'RETRY_WITH_BACKOFF' });
} else if (error.status >= 500) {
// Server error → retry
dispatch({ type: 'RETRY_SERVER_ERROR' });
} else if (error.status === 401) {
// Auth error → redirect to login
redirectToLogin();
} else {
// Client error → show to user
dispatch({ type: 'SHOW_ERROR', payload: error.message });
}
};3. Offline Support:
// Queue requests khi offline
const [state, dispatch] = useReducer(reducer, {
queue: [], // Pending requests
isOnline: navigator.onLine,
});
useEffect(() => {
const handleOnline = () => {
dispatch({ type: 'ONLINE' });
// Process queue
state.queue.forEach((request) => {
fetch(request.url, request.options);
});
};
window.addEventListener('online', handleOnline);
return () => window.removeEventListener('online', handleOnline);
}, []);Câu Hỏi Phỏng Vấn
Junior Level:
Q: "Tại sao cần AbortController khi fetch data?"
Expected:
- Prevent race conditions
- Cancel old requests
- Cleanup on unmount
- Example: userId changes → abort previous request
Q: "useEffect cleanup function chạy khi nào?"
Expected:
- Before effect re-runs (deps change)
- Component unmount
- Example: clear interval, abort fetch
Mid Level:
Q: "Làm sao handle race condition trong search?"
Expected:
- Problem: Slower request overwrites faster one
- Solution 1: AbortController
- Solution 2: Ignore stale flag
- Debouncing cũng helps
- Code example
Q: "Optimistic updates là gì? Trade-offs?"
Expected:
- Update UI instantly, confirm later
- Pros: Fast UX, no loading
- Cons: Need rollback, complex
- When: User interactions (likes, posts)
- When not: Critical data (payments)
Senior Level:
Q: "Design data fetching layer cho large app. Explain caching, retry, error handling strategies."
Expected:
- Normalized state for data
- Cache layer (LRU, TTL)
- Retry with exponential backoff
- Error categorization (retry vs show vs ignore)
- Request deduplication
- Prefetching strategies
- Offline queue
- Performance monitoring
War Stories
Story 1: Race Condition in Production
"Ecommerce app có search. Users type nhanh → multiple requests. Bug: Search 'iphone' → shows 'ip' results vì request 'ip' chậm hơn. Mất 2 ngày debug vì chỉ xảy ra với slow network. Fix: AbortController + timestamp check. Lesson: Always cleanup async operations!" - Senior Engineer
Story 2: Optimistic Updates Gone Wrong
"Social app implement optimistic likes. Bug: User spam like → count tăng 10, nhưng API chỉ nhận 1. Vì không handle duplicate requests. Fix: Debounce + prevent multiple optimistic updates cho cùng item. Lesson: Optimistic updates cần deduplication logic." - Tech Lead
Story 3: Silent Retry Saved Us
"API có 10% flaky rate. Users thấy errors liên tục. Implement silent retry (max 2): lần 1 fail → wait 1s → retry → lần 2 fail → show error. Error rate visible cho users giảm 90%. Lesson: Not all errors cần show ngay, retry first!" - Engineering Manager
🎯 PREVIEW NGÀY MAI
Ngày 29: Custom Hooks với useReducer
Bạn sẽ học:
- ✨ Extract reusable async logic
- ✨ Build useFetch, useAsync, useForm hooks
- ✨ Hook composition patterns
- ✨ Share logic across components
- ✨ Test custom hooks
Chuẩn bị:
- Hoàn thành bài tập hôm nay
- Review patterns đã học (normalization, async)
- Nghĩ về logic nào có thể reuse
- Read: https://react.dev/learn/reusing-logic-with-custom-hooks
🎉 Chúc mừng! Bạn đã hoàn thành Ngày 28!
Bạn giờ đã master:
- ✅ useReducer + useEffect pattern
- ✅ Async state management
- ✅ Race condition handling
- ✅ Optimistic updates
- ✅ Retry logic
Tomorrow: Reusable custom hooks! 💪