Skip to content

📅 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:

  1. Reducer có được phép async (await fetch) không? Tại sao?

    • Gợi ý: Reducer phải pure function...
  2. useEffect cleanup function chạy khi nào?

    • Gợi ý: Component unmount, dependencies change...
  3. 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:

jsx
// ❌ 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 đề:

  1. 🔴 3 separate states → Dễ out of sync
  2. 🔴 Duplicate logic → setLoading(false) ở nhiều chỗ
  3. 🔴 Missing cleanup → Race conditions
  4. 🔴 Inconsistent states → loading=true + error != null?
  5. 🔴 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
jsx
// ✅ 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 ⭐

jsx
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:

  1. 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: "..." }
  2. AbortController:

    • Prevents race conditions
    • Cancels in-flight requests
    • Essential for cleanup!
  3. Error Handling:

    • Check error.name !== 'AbortError'
    • Don't dispatch if request was aborted

Demo 2: Advanced Pattern - Search with Debounce ⭐⭐

jsx
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:

  1. Debouncing:

    • Wait 500ms after user stops typing
    • Reduces API calls
    • Better UX + performance
  2. Cleanup Chain:

    User types "a" → setTimeout(500ms)
    User types "ab" (before 500ms) → clearTimeout + new setTimeout
    → Only final "ab" triggers API call
  3. Empty 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 ⭐⭐⭐

jsx
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:

  1. Flow:

    User action
    
    Dispatch optimistic update (instant UI)
    
    API call (background)
    
    Success → Confirm (replace temp with real)
    Error → Rollback (revert to previous state)
  2. Benefits:

    • ✅ Instant feedback (no loading spinners)
    • ✅ Better UX (feels faster)
    • ✅ Graceful error handling (rollback visible)
  3. 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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

PatternCode ComplexityPerformanceUse CaseWinner
Multiple useState✅ Simple❌ Có thể inconsistentSimple fetchesSmall apps
useReducer⚠️ Medium✅ Consistent stateComplex async flowsMedium-Large apps
useReducer + Cache❌ Complex✅✅ Very fastRepeated fetchesHigh-traffic features
Optimistic Updates❌ Complex✅✅ Feels instantUser interactionsSocial 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 actions

Loading States Best Practices

❌ Bad: Boolean loading

jsx
const [loading, setLoading] = useState(false);
// Problem: Can't distinguish initial vs refresh

✅ Good: State machine

jsx
const states = {
  idle: 'idle',
  loading: 'loading',
  success: 'success',
  error: 'error',
};

// Can show different UI for each state

✅ Better: Multiple loading states

jsx
{
  initialLoading: false,  // First load → skeleton
  refreshing: false,      // Pull to refresh → inline spinner
  loadingMore: false,     // Pagination → button spinner
}

Error Handling Strategies

StrategyWhen to UseExample
Show error + retryRecoverable errorsNetwork timeout → "Retry" button
Silent retryTransient errors429 rate limit → auto retry with backoff
Fallback UINon-critical featuresImage load fail → placeholder
Error boundaryCritical errorsComponent crash → error page
Toast notificationBackground operations"Failed to save draft" toast

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

Bug 1: Race Condition 🐛

jsx
// ❌ 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:

  1. Vấn đề gì với code?
  2. Khi nào bug xảy ra?
  3. Làm sao fix?

💡 Giải thích:

  • Multiple requests in-flight
  • Slower request completes last → overwrites newer results
  • User sees wrong data!

✅ Fix:

jsx
// ✅ 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 🐛

jsx
// ❌ 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:

  1. Code có vẻ đúng, tại sao infinite loop?
  2. dispatch có stable không?
  3. Làm sao fix?

💡 Giải thích:

  • dispatch IS 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:

jsx
// ✅ 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 🐛

jsx
// ❌ 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:

  1. Vấn đề gì xảy ra khi component unmount?
  2. Console warning gì?
  3. 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:

jsx
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:

  1. Form với multiple fields (title, description, tags)
  2. Auto-save 2 giây sau khi user ngừng typing
  3. Show save status: "Saved", "Saving...", "Error"
  4. Retry nếu save fails
  5. Don't save nếu no changes

State shape:

jsx
{
  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
jsx
/**
 * 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:

  1. Fetch messages mỗi 5 giây (polling)
  2. Display messages with timestamps
  3. Send message (optimistic update)
  4. Stop polling khi user inactive (5 phút)
  5. Resume polling khi user active
  6. Handle errors gracefully

State shape:

jsx
{
  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
jsx
/**
 * 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

  1. React Docs - useEffect:

  2. AbortController:

Đọc thêm

  1. Race Conditions in React:

  2. Optimistic UI Updates:


🔗 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:

jsx
// ✅ 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:

jsx
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:

jsx
// 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:

  1. 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
  2. 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:

  1. 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
  2. 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:

  1. 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ị:


🎉 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! 💪

Personal tech knowledge base