Skip to content

📅 NGÀY 24: Custom Hooks - Basics & Reusable Logic

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

Sau bài học này, bạn sẽ:

  • [ ] Hiểu custom hooks là gì và tại sao cần thiết
  • [ ] Nắm vững naming convention và rules of hooks
  • [ ] Tạo được custom hooks để extract và reuse logic
  • [ ] Compose multiple hooks together
  • [ ] Biết khi nào nên tạo custom hook vs khi nào không
  • [ ] Test custom hooks properly
  • [ ] Tránh được common pitfalls và anti-patterns

🤔 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 này:

  1. useState, useEffect, useRef là gì?

    • Built-in React hooks để manage state, side effects, và refs
  2. Làm sao reuse logic giữa nhiều components?

    • Trước đây: HOCs, render props. Bây giờ: Custom hooks! 🎯
  3. useLayoutEffect dùng khi nào?

    • DOM measurements trước paint, preventing visual flash

Hôm nay: Chúng ta sẽ học cách extract hook logic thành reusable custom hooks! 🎣


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

1.1 Vấn Đề Thực Tế

Hãy xem tình huống này - nhiều components cần same logic:

jsx
// ❌ VẤN ĐỀ: Duplicate logic across components

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

  useEffect(() => {
    setLoading(true);
    fetch('/api/user')
      .then((res) => res.json())
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, []);

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

function ProductList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch('/api/products')
      .then((res) => res.json())
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return (
    <div>
      {data.map((p) => (
        <div key={p.id}>{p.name}</div>
      ))}
    </div>
  );
}

// ⚠️ Same logic repeated!
// ⚠️ Hard to maintain
// ⚠️ Bug fixes need to be applied everywhere

Vấn đề:

  • Logic duplicate across nhiều components
  • Khó maintain - sửa 1 chỗ phải sửa nhiều nơi
  • Bugs có thể inconsistent
  • Code bloat

1.2 Giải Pháp: Custom Hooks

jsx
// ✅ GIẢI PHÁP: Extract logic into custom hook

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
}

// Usage - DRY and clean!
function UserProfile() {
  const { data, loading, error } = useFetch('/api/user');

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

function ProductList() {
  const { data, loading, error } = useFetch('/api/products');

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return (
    <div>
      {data.map((p) => (
        <div key={p.id}>{p.name}</div>
      ))}
    </div>
  );
}

Benefits:

  • ✅ Logic reused across components
  • ✅ Single source of truth
  • ✅ Easy to maintain
  • ✅ Easy to test
  • ✅ Composable

1.3 Mental Model

Custom hooks là functions that use hooks:

Regular Function          Custom Hook
─────────────────────────────────────────
function add(a, b) {     function useCounter(initial) {
  return a + b;            const [count, setCount] = useState(initial);
}
                           const increment = () => setCount(c => c + 1);

                           return { count, increment };
                         }

Call anywhere            Call only in React components/hooks
Pure computation         Can use React hooks
No side effects          Can have side effects

Visualization:

Component

    ├─ Built-in Hooks
    │    ├─ useState
    │    ├─ useEffect
    │    └─ useRef

    └─ Custom Hooks
         ├─ useFetch (uses useState + useEffect)
         ├─ useLocalStorage (uses useState + useEffect)
         └─ useDebounce (uses useState + useEffect)

              └─ Can use other custom hooks!
                   └─ useTimeout

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

❌ Hiểu lầm 1: "Custom hooks là special syntax"

jsx
// ❌ SAI: Nghĩ custom hook cần special declaration
// Không có gì đặc biệt! Chỉ là function thôi.

// ✅ Custom hook chỉ là function theo naming convention
function useMyHook() {
  // Must start with "use"
  const [state, setState] = useState(0);
  return state;
}

// That's it! Không có magic nào cả.

❌ Hiểu lầm 2: "Custom hooks share state giữa components"

jsx
// ❌ SAI: Nghĩ 2 components dùng cùng hook share state
function useCounter() {
  const [count, setCount] = useState(0);
  return { count, setCount };
}

function ComponentA() {
  const { count } = useCounter();
  return <div>A: {count}</div>;
}

function ComponentB() {
  const { count } = useCounter();
  return <div>B: {count}</div>; // ⚠️ KHÔNG share với ComponentA!
}

Thực tế:

  • Mỗi component call hook → độc lập instance của hook logic
  • State KHÔNG share giữa components
  • Giống như gọi useState nhiều lần → separate states
jsx
// Mỗi component có riêng state:
ComponentA: count = 0 (instance 1)
ComponentB: count = 0 (instance 2)

// Increment trong A → chỉ A update
ComponentA: count = 1
ComponentB: count = 0 (unchanged)

❌ Hiểu lầm 3: "Custom hooks phải return object"

jsx
// Custom hook có thể return bất cứ gì:

// ✅ Return array (như useState)
function useToggle(initial) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue((v) => !v);
  return [value, toggle]; // Array
}

// ✅ Return object
function useCounter(initial) {
  const [count, setCount] = useState(initial);
  return { count, setCount }; // Object
}

// ✅ Return primitive
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline; // Just boolean
}

// ✅ Return function
function useDebounce(callback, delay) {
  const timeoutRef = useRef(null);

  return (...args) => {
    clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => callback(...args), delay);
  }; // Function
}

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

Demo 1: Pattern Cơ Bản - useToggle ⭐

jsx
/**
 * 🎯 Mục tiêu: Simple custom hook để toggle boolean state
 * 💡 Pattern: Extract common toggle logic
 */

import { useState } from 'react';

// ✅ Custom Hook
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = () => setValue((v) => !v);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);

  return [value, { toggle, setTrue, setFalse }];
}

// Usage Examples
function ToggleDemo() {
  const [isOn, { toggle, setTrue, setFalse }] = useToggle(false);
  const [isVisible, toggleVisible] = useToggle(true);

  return (
    <div style={{ padding: '20px' }}>
      <h2>useToggle Demo</h2>

      {/* Example 1: Light switch */}
      <div style={{ marginBottom: '20px' }}>
        <h3>Light Switch</h3>
        <div
          style={{
            width: '100px',
            height: '100px',
            backgroundColor: isOn ? 'yellow' : 'gray',
            borderRadius: '50%',
            margin: '10px 0',
          }}
        />
        <button onClick={toggle}>Toggle</button>
        <button onClick={setTrue}>Turn On</button>
        <button onClick={setFalse}>Turn Off</button>
      </div>

      {/* Example 2: Show/Hide content */}
      <div>
        <h3>Show/Hide Content</h3>
        <button onClick={toggleVisible.toggle}>
          {isVisible ? 'Hide' : 'Show'} Content
        </button>
        {isVisible && (
          <div
            style={{
              marginTop: '10px',
              padding: '15px',
              backgroundColor: '#e3f2fd',
              borderRadius: '4px',
            }}
          >
            This content can be toggled!
          </div>
        )}
      </div>
    </div>
  );
}

Breakdown:

jsx
// 1. Hook definition - just a function
function useToggle(initialValue = false) {
  // 2. Use built-in hooks inside
  const [value, setValue] = useState(initialValue);

  // 3. Define helper functions
  const toggle = () => setValue((v) => !v);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);

  // 4. Return API for consumers
  return [value, { toggle, setTrue, setFalse }];
}

// Why this pattern?
// ✅ Reusable across components
// ✅ Encapsulates toggle logic
// ✅ Cleaner than inline setState everywhere
// ✅ Consistent behavior

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

jsx
/**
 * 🎯 Use case: Sync state với localStorage
 * 💼 Real-world: Theme preferences, form drafts, settings
 * ⚠️ Edge cases: JSON parse errors, storage quota, SSR
 */

import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // State để store giá trị
  const [storedValue, setStoredValue] = useState(() => {
    // ⚠️ SSR: localStorage không tồn tại
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      // Get from localStorage by key
      const item = window.localStorage.getItem(key);
      // Parse stored json or return initialValue
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // ⚠️ Parse error → return initialValue
      console.error('Error reading from localStorage:', error);
      return initialValue;
    }
  });

  // Return wrapped version of useState's setter function
  const setValue = (value) => {
    try {
      // Allow value to be a function (same API as useState)
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;

      // Save state
      setStoredValue(valueToStore);

      // Save to localStorage
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      // ⚠️ Storage quota exceeded
      console.error('Error saving to localStorage:', error);
    }
  };

  return [storedValue, setValue];
}

// Demo App
function LocalStorageDemo() {
  const [name, setName] = useLocalStorage('user-name', '');
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [todos, setTodos] = useLocalStorage('todos', []);
  const [newTodo, setNewTodo] = useState('');

  const addTodo = () => {
    if (newTodo.trim()) {
      setTodos([...todos, { id: Date.now(), text: newTodo }]);
      setNewTodo('');
    }
  };

  return (
    <div
      style={{
        padding: '20px',
        backgroundColor: theme === 'dark' ? '#222' : '#fff',
        color: theme === 'dark' ? '#fff' : '#000',
        minHeight: '100vh',
      }}
    >
      <h2>useLocalStorage Demo</h2>
      <p>Refresh page to see persistence! 🔄</p>

      {/* Theme Toggle */}
      <div style={{ marginBottom: '20px' }}>
        <label>
          Theme:
          <select
            value={theme}
            onChange={(e) => setTheme(e.target.value)}
            style={{ marginLeft: '10px', padding: '5px' }}
          >
            <option value='light'>Light</option>
            <option value='dark'>Dark</option>
          </select>
        </label>
      </div>

      {/* Name Input */}
      <div style={{ marginBottom: '20px' }}>
        <label>
          Name:
          <input
            type='text'
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder='Enter your name...'
            style={{
              marginLeft: '10px',
              padding: '5px',
              backgroundColor: theme === 'dark' ? '#333' : '#fff',
              color: theme === 'dark' ? '#fff' : '#000',
              border: '1px solid #ccc',
            }}
          />
        </label>
        {name && <p>Hello, {name}! 👋</p>}
      </div>

      {/* Todo List */}
      <div>
        <h3>Persistent Todo List</h3>
        <div style={{ display: 'flex', gap: '10px', marginBottom: '10px' }}>
          <input
            type='text'
            value={newTodo}
            onChange={(e) => setNewTodo(e.target.value)}
            onKeyPress={(e) => e.key === 'Enter' && addTodo()}
            placeholder='Add todo...'
            style={{
              flex: 1,
              padding: '8px',
              backgroundColor: theme === 'dark' ? '#333' : '#fff',
              color: theme === 'dark' ? '#fff' : '#000',
              border: '1px solid #ccc',
            }}
          />
          <button
            onClick={addTodo}
            style={{ padding: '8px 15px' }}
          >
            Add
          </button>
        </div>

        <ul style={{ listStyle: 'none', padding: 0 }}>
          {todos.map((todo) => (
            <li
              key={todo.id}
              style={{
                padding: '10px',
                marginBottom: '5px',
                backgroundColor: theme === 'dark' ? '#333' : '#f0f0f0',
                borderRadius: '4px',
                display: 'flex',
                justifyContent: 'space-between',
                alignItems: 'center',
              }}
            >
              <span>{todo.text}</span>
              <button
                onClick={() => setTodos(todos.filter((t) => t.id !== todo.id))}
                style={{
                  backgroundColor: '#dc3545',
                  color: 'white',
                  border: 'none',
                  padding: '5px 10px',
                  borderRadius: '4px',
                  cursor: 'pointer',
                }}
              >
                Delete
              </button>
            </li>
          ))}
        </ul>
      </div>

      {/* Debug Info */}
      <details style={{ marginTop: '20px' }}>
        <summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
          Debug Info
        </summary>
        <pre
          style={{
            marginTop: '10px',
            padding: '10px',
            backgroundColor: theme === 'dark' ? '#333' : '#f5f5f5',
            borderRadius: '4px',
            fontSize: '12px',
          }}
        >
          {JSON.stringify({ name, theme, todos }, null, 2)}
        </pre>
      </details>
    </div>
  );
}

Key Patterns:

jsx
// Pattern 1: Lazy initialization
const [storedValue, setStoredValue] = useState(() => {
  // ✅ Function runs once on mount
  // Expensive operation (localStorage read) happens once
  return expensiveRead();
});

// Pattern 2: Functional setter support
const setValue = (value) => {
  const valueToStore = value instanceof Function ? value(storedValue) : value;
  // ✅ Supports both: setValue(5) và setValue(prev => prev + 1)
};

// Pattern 3: Error handling
try {
  // Risky operation
} catch (error) {
  // ✅ Graceful fallback
  console.error('Error:', error);
  return initialValue;
}

// Pattern 4: SSR safety
if (typeof window === 'undefined') {
  return initialValue; // ✅ Server-side safe
}

Demo 3: Edge Cases - useDebounce ⭐⭐⭐

jsx
/**
 * 🎯 Use case: Debounce value changes (search input, API calls)
 * ⚠️ Edge cases:
 *    - Value changes rapidly
 *    - Component unmounts during delay
 *    - Delay changes mid-execution
 *    - Initial value handling
 */

import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // Set timeout to update debounced value
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup: clear timeout on value/delay change or unmount
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]); // Re-run when value or delay changes

  return debouncedValue;
}

// Search Demo
function SearchDemo() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);
  const [results, setResults] = useState([]);
  const [isSearching, setIsSearching] = useState(false);

  // Effect để search khi debounced value changes
  useEffect(() => {
    if (debouncedSearchTerm) {
      setIsSearching(true);

      // Mock API call
      setTimeout(() => {
        const mockResults = [
          `Result 1 for "${debouncedSearchTerm}"`,
          `Result 2 for "${debouncedSearchTerm}"`,
          `Result 3 for "${debouncedSearchTerm}"`,
        ];
        setResults(mockResults);
        setIsSearching(false);
      }, 300);
    } else {
      setResults([]);
    }
  }, [debouncedSearchTerm]); // ✅ Only search when debounced value changes

  return (
    <div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
      <h2>useDebounce Demo - Search</h2>

      <input
        type='text'
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder='Type to search...'
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          border: '2px solid #007bff',
          borderRadius: '4px',
        }}
      />

      <div style={{ marginTop: '10px', fontSize: '14px', color: '#666' }}>
        <p>Typing: "{searchTerm}"</p>
        <p>Searching for: "{debouncedSearchTerm}"</p>
        <p>Delay: 500ms after you stop typing</p>
      </div>

      {isSearching && (
        <div
          style={{ marginTop: '20px', textAlign: 'center', color: '#007bff' }}
        >
          Searching...
        </div>
      )}

      {!isSearching && results.length > 0 && (
        <ul style={{ marginTop: '20px' }}>
          {results.map((result, index) => (
            <li
              key={index}
              style={{
                padding: '10px',
                marginBottom: '5px',
                backgroundColor: '#f0f0f0',
                borderRadius: '4px',
              }}
            >
              {result}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Timeline visualization:

User types "react"
─────────────────────────────────────────────────────────
Time:    0ms   100ms  200ms  300ms  400ms  500ms  600ms
Input:   r     re     rea    reac   react  (stop)
Timeout: [set] [clear+set] [clear+set] [clear+set] [clear+set] [wait] [FIRE!]
Search:                                                         [API call]

Without debounce:
API:     call  call   call   call   call
         ❌ 5 unnecessary API calls!

With debounce:
API:                                                            call
         ✅ Only 1 API call after user stops typing!

Advanced useDebounce with callback:

jsx
// Alternative: Debounce callback instead of value
function useDebouncedCallback(callback, delay) {
  const timeoutRef = useRef(null);
  const callbackRef = useRef(callback);

  // Keep callback ref updated
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  return (...args) => {
    // Clear previous timeout
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    // Set new timeout
    timeoutRef.current = setTimeout(() => {
      callbackRef.current(...args);
    }, delay);
  };
}

// Usage:
function SearchWithCallback() {
  const [results, setResults] = useState([]);

  const debouncedSearch = useDebouncedCallback((term) => {
    fetch(`/api/search?q=${term}`)
      .then((res) => res.json())
      .then(setResults);
  }, 500);

  return <input onChange={(e) => debouncedSearch(e.target.value)} />;
}

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

⭐ Exercise 1: useCounter Hook (15 phút)

jsx
/**
 * 🎯 Mục tiêu: Tạo reusable counter hook
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: useReducer (chưa học)
 *
 * Requirements:
 * 1. Initial value support
 * 2. Increment, decrement, reset functions
 * 3. Set to specific value
 * 4. Min/max bounds (optional)
 * 5. Step size configurable
 *
 * 💡 Gợi ý:
 * - useState cho counter value
 * - Return object với value và methods
 * - Clamp giá trị trong bounds nếu có
 */

import { useState } from 'react';

// TODO: Implement useCounter
function useCounter(initialValue = 0, { min, max, step = 1 } = {}) {
  // Step 1: State cho counter value
  // const [count, setCount] = useState(initialValue);
  // Step 2: Increment function
  // const increment = () => {
  //   setCount(current => {
  //     const newValue = current + step;
  //     if (max !== undefined) return Math.min(newValue, max);
  //     return newValue;
  //   });
  // };
  // Step 3: Decrement function
  // const decrement = () => { ... };
  // Step 4: Reset function
  // const reset = () => setCount(initialValue);
  // Step 5: Set function
  // const set = (value) => { ... with bounds checking };
  // Step 6: Return API
  // return { count, increment, decrement, reset, set };
}

// 🎯 NHIỆM VỤ CỦA BẠN: Implement hook and test with this component
function CounterDemo() {
  const counter = useCounter(0, { min: 0, max: 10, step: 1 });
  const bigCounter = useCounter(0, { step: 5 });

  return (
    <div style={{ padding: '20px' }}>
      <h2>useCounter Demo</h2>

      {/* Basic Counter */}
      <div style={{ marginBottom: '30px' }}>
        <h3>Basic Counter (0-10)</h3>
        <div style={{ fontSize: '32px', fontWeight: 'bold', margin: '10px 0' }}>
          {counter.count}
        </div>
        <div style={{ display: 'flex', gap: '10px' }}>
          <button onClick={counter.decrement}>-1</button>
          <button onClick={counter.increment}>+1</button>
          <button onClick={counter.reset}>Reset</button>
          <button onClick={() => counter.set(5)}>Set to 5</button>
        </div>
      </div>

      {/* Big Step Counter */}
      <div>
        <h3>Big Step Counter (step: 5)</h3>
        <div style={{ fontSize: '32px', fontWeight: 'bold', margin: '10px 0' }}>
          {bigCounter.count}
        </div>
        <div style={{ display: 'flex', gap: '10px' }}>
          <button onClick={bigCounter.decrement}>-5</button>
          <button onClick={bigCounter.increment}>+5</button>
          <button onClick={bigCounter.reset}>Reset</button>
        </div>
      </div>
    </div>
  );
}

// ✅ Expected behavior:
// - Counter stays within bounds (0-10)
// - Step size applied correctly
// - Reset returns to initial value
// - Set clamps to bounds
💡 Solution
jsx
/**
 * Custom hook tạo bộ đếm có thể tùy chỉnh giới hạn, bước nhảy
 * @param {number} initialValue - Giá trị ban đầu của bộ đếm
 * @param {Object} options - Tùy chọn cấu hình
 * @param {number} [options.min] - Giá trị tối thiểu (optional)
 * @param {number} [options.max] - Giá trị tối đa (optional)
 * @param {number} [options.step=1] - Bước nhảy mỗi lần tăng/giảm
 * @returns {Object} API của counter
 */
function useCounter(initialValue = 0, { min, max, step = 1 } = {}) {
  const [count, setCount] = useState(initialValue);

  const clamp = (value) => {
    if (min !== undefined) value = Math.max(value, min);
    if (max !== undefined) value = Math.min(value, max);
    return value;
  };

  const increment = () => {
    setCount((current) => clamp(current + step));
  };

  const decrement = () => {
    setCount((current) => clamp(current - step));
  };

  const reset = () => {
    setCount(initialValue);
  };

  const set = (value) => {
    setCount(clamp(value));
  };

  return {
    count,
    increment,
    decrement,
    reset,
    set,
  };
}

// Ví dụ kết quả khi sử dụng:
// const counter = useCounter(0, { min: 0, max: 10, step: 1 });
// counter.count     → 0
// counter.increment() → count = 1
// counter.increment() → count = 2
// ... tiếp tục đến 10 thì không tăng nữa
// counter.decrement() → count = 9
// counter.set(15)     → count = 10 (bị clamp)
// counter.reset()     → count = 0

// const bigCounter = useCounter(0, { step: 5 });
// bigCounter.increment() → count = 5
// bigCounter.decrement() → count = 0

⭐⭐ Exercise 2: useClickOutside Hook (25 phút)

jsx
/**
 * 🎯 Mục tiêu: Reusable click-outside detection hook
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario:
 * Nhiều components cần close khi click outside (dropdown, modal, tooltip).
 * Extract logic này thành hook để reuse.
 *
 * Requirements:
 * 1. Accept ref và callback
 * 2. Detect clicks outside element
 * 3. Cleanup listeners properly
 * 4. Support multiple refs (bonus)
 * 5. Keyboard escape support (bonus)
 *
 * API Design:
 * useClickOutside(ref, callback, options)
 */

import { useEffect, useRef } from 'react';

function useClickOutside(ref, handler, options = {}) {
  const { enabled = true, keys = ['Escape'] } = options;

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

    // TODO: Implement
    // 1. Handle mouse clicks
    // const handleClick = (event) => {
    //   if (ref.current && !ref.current.contains(event.target)) {
    //     handler(event);
    //   }
    // };

    // 2. Handle keyboard (Escape)
    // const handleKeyDown = (event) => {
    //   if (keys.includes(event.key)) {
    //     handler(event);
    //   }
    // };

    // 3. Add listeners
    // document.addEventListener('mousedown', handleClick);
    // document.addEventListener('keydown', handleKeyDown);

    // 4. Cleanup
    // return () => {
    //   document.removeEventListener('mousedown', handleClick);
    //   document.removeEventListener('keydown', handleKeyDown);
    // };
  }, [ref, handler, enabled, keys]);
}

// Demo Components
function DropdownMenu() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef(null);

  useClickOutside(dropdownRef, () => setIsOpen(false), {
    enabled: isOpen,
  });

  return (
    <div style={{ position: 'relative', display: 'inline-block' }}>
      <button onClick={() => setIsOpen(!isOpen)}>
        Menu {isOpen ? '▲' : '▼'}
      </button>

      {isOpen && (
        <div
          ref={dropdownRef}
          style={{
            position: 'absolute',
            top: '100%',
            left: 0,
            marginTop: '5px',
            backgroundColor: 'white',
            border: '1px solid #ccc',
            borderRadius: '4px',
            boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
            minWidth: '150px',
            zIndex: 1000,
          }}
        >
          <div style={{ padding: '10px', cursor: 'pointer' }}>Option 1</div>
          <div style={{ padding: '10px', cursor: 'pointer' }}>Option 2</div>
          <div style={{ padding: '10px', cursor: 'pointer' }}>Option 3</div>
        </div>
      )}
    </div>
  );
}

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);

  useClickOutside(modalRef, onClose, {
    enabled: isOpen,
  });

  if (!isOpen) return null;

  return (
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        backgroundColor: 'rgba(0,0,0,0.5)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        zIndex: 1000,
      }}
    >
      <div
        ref={modalRef}
        style={{
          backgroundColor: 'white',
          padding: '20px',
          borderRadius: '8px',
          maxWidth: '400px',
        }}
      >
        {children}
      </div>
    </div>
  );
}

// Test Component
function ClickOutsideDemo() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div style={{ padding: '20px' }}>
      <h2>useClickOutside Demo</h2>

      <div style={{ marginBottom: '20px' }}>
        <DropdownMenu />
      </div>

      <button onClick={() => setShowModal(true)}>Open Modal</button>

      <Modal
        isOpen={showModal}
        onClose={() => setShowModal(false)}
      >
        <h3>Modal Title</h3>
        <p>Click outside or press Escape to close</p>
        <button onClick={() => setShowModal(false)}>Close</button>
      </Modal>
    </div>
  );
}

// 🎯 Expected behavior:
// - Dropdown closes on outside click
// - Modal closes on outside click or Escape
// - No crashes, proper cleanup
// - Works with multiple instances
💡 Solution
jsx
/**
 * Custom hook phát hiện click bên ngoài một element (hoặc nhấn Escape)
 * @param {React.RefObject} ref - ref của element cần theo dõi
 * @param {Function} handler - hàm được gọi khi click outside hoặc nhấn phím thoát
 * @param {Object} [options={}] - tùy chọn
 * @param {boolean} [options.enabled=true] - bật/tắt hook
 * @param {string[]} [options.keys=['Escape']] - danh sách phím thoát (thường là Escape)
 */
function useClickOutside(ref, handler, options = {}) {
  const { enabled = true, keys = ['Escape'] } = options;

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

    const handleClick = (event) => {
      // Bỏ qua nếu click vào chính element hoặc con của nó
      if (ref.current && !ref.current.contains(event.target)) {
        handler(event);
      }
    };

    const handleKeyDown = (event) => {
      if (keys.includes(event.key)) {
        handler(event);
      }
    };

    // Sử dụng mousedown thay vì click để bắt được sớm hơn (trước focus change)
    document.addEventListener('mousedown', handleClick);
    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('mousedown', handleClick);
      document.removeEventListener('keydown', handleKeyDown);
    };
  }, [ref, handler, enabled, ...keys]); // keys là array → spread để theo dõi thay đổi
}

// Ví dụ kết quả khi sử dụng:
// → Dropdown / Modal sẽ đóng khi:
//   • Click chuột ra ngoài vùng ref
//   • Nhấn phím Escape (mặc định)
// → Không đóng khi click bên trong vùng ref
// → Không lắng nghe sự kiện khi enabled = false
// → Cleanup đúng cách khi component unmount hoặc enabled thay đổi

⭐⭐⭐ Exercise 3: useAsync Hook (40 phút)

jsx
/**
 * 🎯 Mục tiêu: Generic async operation hook
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 Product Requirements:
 * User Story: "Là developer, tôi muốn reusable hook cho async operations"
 *
 * ✅ Acceptance Criteria:
 * - [ ] Execute async function
 * - [ ] Track loading, data, error states
 * - [ ] Support manual trigger (lazy execution)
 * - [ ] Support immediate execution
 * - [ ] Cancellation support
 * - [ ] Retry functionality
 *
 * 🎨 Technical Constraints:
 * - AbortController cho cancellation
 * - Cleanup on unmount
 * - No memory leaks
 *
 * 🚨 Edge Cases:
 * - Component unmounts during request
 * - Rapid sequential calls
 * - Error handling
 */

import { useState, useEffect, useRef, useCallback } from 'react';

function useAsync(asyncFunction, options = {}) {
  const { immediate = false, onSuccess, onError } = options;

  const [state, setState] = useState({
    loading: false,
    data: null,
    error: null,
  });
  const lastArgsRef = useRef([]); // Nơi lưu trữ tham số cuối cùng
  const abortControllerRef = useRef(null);
  const isMountedRef = useRef(true);

  // Execute async function
  const execute = useCallback(
    async (...args) => {
      lastArgsRef.current = args; // Lưu lại tham số mỗi khi hàm được gọi
      // TODO: Implement
      // 1. Cancel previous request
      // 2. Create new AbortController
      // 3. Set loading state
      // 4. Try execute asyncFunction
      // 5. Update data on success
      // 6. Update error on failure
      // 7. Call callbacks
      // 8. Only update state if still mounted
    },
    [asyncFunction],
  );

  // Reset state
  const reset = useCallback(() => {
    setState({ loading: false, data: null, error: null });
  }, []);

  // Retry
  const retry = useCallback(() => {
    reset();
    // Sử dụng tham số đã lưu trong Ref để chạy lại
    return execute(...lastArgsRef.current);
  }, [execute, reset]);

  // Immediate execution
  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [immediate, execute]);

  // Cleanup
  useEffect(() => {
    return () => {
      isMountedRef.current = false;
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);

  return {
    ...state,
    execute,
    reset,
    retry,
  };
}

// Demo: User Profile Fetcher
function UserProfileDemo() {
  const [userId, setUserId] = useState(1);

  const {
    loading,
    data: user,
    error,
    execute: fetchUser,
    retry,
  } = useAsync(
    async (id) => {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/users/${id}`,
      );
      if (!response.ok) throw new Error('Failed to fetch');
      return response.json();
    },
    {
      immediate: true,
      onSuccess: (data) => console.log('User loaded:', data.name),
      onError: (error) => console.error('Error:', error),
    },
  );

  useEffect(() => {
    fetchUser(userId);
  }, [userId, fetchUser]);

  return (
    <div style={{ padding: '20px' }}>
      <h2>useAsync Demo - User Profile</h2>

      <div style={{ marginBottom: '20px' }}>
        <label>
          User ID:
          <input
            type='number'
            min='1'
            max='10'
            value={userId}
            onChange={(e) => setUserId(Number(e.target.value))}
            style={{ marginLeft: '10px', padding: '5px' }}
          />
        </label>
      </div>

      {loading && <div>Loading user #{userId}...</div>}

      {error && (
        <div
          style={{
            padding: '15px',
            backgroundColor: '#fee',
            border: '1px solid #fcc',
            borderRadius: '4px',
            marginBottom: '10px',
          }}
        >
          Error: {error.message}
          <button
            onClick={retry}
            style={{ marginLeft: '10px' }}
          >
            Retry
          </button>
        </div>
      )}

      {user && !loading && (
        <div
          style={{
            padding: '15px',
            backgroundColor: '#f0f0f0',
            borderRadius: '4px',
          }}
        >
          <h3>{user.name}</h3>
          <p>Email: {user.email}</p>
          <p>Phone: {user.phone}</p>
          <p>Website: {user.website}</p>
        </div>
      )}
    </div>
  );
}

// 🎯 Expected behavior:
// - Fetches user on mount
// - Fetches new user when ID changes
// - Shows loading state
// - Handles errors with retry
// - Cancels previous request when new one starts
// - No updates after unmount
💡 Solution
jsx
/**
 * Custom hook xử lý các tác vụ async một cách tổng quát
 * Hỗ trợ: loading state, error handling, cancellation, retry, manual/lazy execution
 * @param {Function} asyncFunction - hàm async cần thực thi (có thể nhận tham số)
 * @param {Object} [options={}] - tùy chọn cấu hình
 * @param {boolean} [options.immediate=true] - tự động chạy ngay khi mount
 * @param {Function} [options.onSuccess] - callback khi thành công
 * @param {Function} [options.onError] - callback khi lỗi
 * @returns {{
 *   loading: boolean,
 *   data: any,
 *   error: Error | null,
 *   execute: (...args: any[]) => Promise<void>,
 *   reset: () => void,
 *   retry: (...args: any[]) => Promise<void>
 * }}
 */
function useAsync(asyncFunction, options = {}) {
  const { immediate = true, onSuccess, onError } = options;

  const [state, setState] = useState({
    loading: false,
    data: null,
    error: null,
  });

  const lastArgsRef = useRef([]); // Nơi lưu trữ tham số cuối cùng
  const abortControllerRef = useRef(null);
  const isMountedRef = useRef(true);

  const execute = useCallback(
    async (...args) => {
      lastArgsRef.current = args; // Lưu lại tham số mỗi khi hàm được gọi

      // Hủy request trước đó nếu đang chạy
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      // Tạo AbortController mới
      abortControllerRef.current = new AbortController();

      setState({ loading: true, data: null, error: null });

      try {
        const result = await asyncFunction(...args, {
          signal: abortControllerRef.current.signal,
        });

        // Chỉ update state nếu component vẫn mounted
        if (isMountedRef.current) {
          setState({ loading: false, data: result, error: null });
          onSuccess?.(result);
        }
      } catch (err) {
        // Bỏ qua lỗi AbortError (do hủy chủ động)
        if (err.name !== 'AbortError' && isMountedRef.current) {
          setState({ loading: false, data: null, error: err });
          onError?.(err);
        }
      }
    },
    [asyncFunction, onSuccess, onError],
  );

  const reset = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
      abortControllerRef.current = null;
    }
    setState({ loading: false, data: null, error: null });
  }, []);

  const retry = useCallback(() => {
    reset();
    // Sử dụng tham số đã lưu trong Ref để chạy lại
    return execute(...lastArgsRef.current);
  }, [execute, reset]);

  // Tự động chạy lần đầu nếu immediate = true
  useEffect(() => {
    if (immediate) {
      execute();
    }
    // Cleanup khi unmount
    return () => {
      isMountedRef.current = false;
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [immediate, execute]);

  return {
    ...state,
    execute,
    reset,
    retry,
  };
}

// Ví dụ kết quả khi sử dụng:
// const { loading, data: user, error, execute: fetchUser, retry } = useAsync(
//   async (id) => {
//     const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
//     if (!res.ok) throw new Error('Fetch failed');
//     return res.json();
//   },
//   { immediate: true }
// );

// → loading = true → false
// → user = {id: 1, name: "Leanne Graham", ...}
// → error = null hoặc Error object
// → fetchUser(5)      → tải user id 5
// → retry()           → thử lại lần cuối cùng
// → Khi đổi userId → request cũ bị abort, request mới chạy

⭐⭐⭐⭐ Exercise 4: useForm Hook (60 phút)

jsx
/**
 * 🎯 Mục tiêu: Form management hook với validation
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Research & Design (20 phút)
 *
 * Requirements:
 * - Manage form values
 * - Handle changes
 * - Validation (sync and async)
 * - Touched fields tracking
 * - Submit handling
 * - Reset functionality
 * - Error messages
 *
 * API Design:
 * const {
 *   values,
 *   errors,
 *   touched,
 *   handleChange,
 *   handleBlur,
 *   handleSubmit,
 *   reset,
 *   isValid,
 *   isSubmitting
 * } = useForm(initialValues, validate, onSubmit);
 *
 * 💻 PHASE 2: Implementation (30 phút)
 * 🧪 PHASE 3: Testing (10 phút)
 */

import { useState, useCallback } from 'react';

function useForm(initialValues, validate, onSubmit) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Handle field change
  const handleChange = useCallback(
    (event) => {
      const { name, value } = event.target;

      setValues((prev) => ({
        ...prev,
        [name]: value,
      }));

      // Clear error when user starts typing
      if (errors[name]) {
        setErrors((prev) => ({
          ...prev,
          [name]: undefined,
        }));
      }
    },
    [errors],
  );

  // Handle field blur
  const handleBlur = useCallback(
    (event) => {
      const { name } = event.target;

      setTouched((prev) => ({
        ...prev,
        [name]: true,
      }));

      // Validate field on blur
      if (validate) {
        const fieldErrors = validate(values);
        if (fieldErrors[name]) {
          setErrors((prev) => ({
            ...prev,
            [name]: fieldErrors[name],
          }));
        }
      }
    },
    [values, validate],
  );

  // Handle submit
  const handleSubmit = useCallback(
    async (event) => {
      event?.preventDefault();

      // Mark all fields as touched
      const allTouched = Object.keys(values).reduce((acc, key) => {
        acc[key] = true;
        return acc;
      }, {});
      setTouched(allTouched);

      // Validate all fields
      const validationErrors = validate ? validate(values) : {};
      setErrors(validationErrors);

      // If no errors, submit
      if (Object.keys(validationErrors).length === 0) {
        setIsSubmitting(true);
        try {
          await onSubmit(values);
        } catch (error) {
          console.error('Submit error:', error);
        } finally {
          setIsSubmitting(false);
        }
      }
    },
    [values, validate, onSubmit],
  );

  // Reset form
  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);

  // Check if form is valid
  const isValid = Object.keys(errors).length === 0;

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    isValid,
    isSubmitting,
  };
}

// Demo: Registration Form
function RegistrationForm() {
  const validate = (values) => {
    const errors = {};

    if (!values.username) {
      errors.username = 'Username is required';
    } else if (values.username.length < 3) {
      errors.username = 'Username must be at least 3 characters';
    }

    if (!values.email) {
      errors.email = 'Email is required';
    } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) {
      errors.email = 'Invalid email address';
    }

    if (!values.password) {
      errors.password = 'Password is required';
    } else if (values.password.length < 6) {
      errors.password = 'Password must be at least 6 characters';
    }

    if (values.password !== values.confirmPassword) {
      errors.confirmPassword = 'Passwords must match';
    }

    return errors;
  };

  const handleSubmit = async (values) => {
    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 1000));
    console.log('Form submitted:', values);
    alert('Registration successful!');
  };

  const form = useForm(
    {
      username: '',
      email: '',
      password: '',
      confirmPassword: '',
    },
    validate,
    handleSubmit,
  );

  return (
    <div style={{ padding: '20px', maxWidth: '400px', margin: '0 auto' }}>
      <h2>useForm Demo - Registration</h2>

      <form onSubmit={form.handleSubmit}>
        {/* Username */}
        <div style={{ marginBottom: '15px' }}>
          <label
            style={{
              display: 'block',
              marginBottom: '5px',
              fontWeight: 'bold',
            }}
          >
            Username:
          </label>
          <input
            type='text'
            name='username'
            value={form.values.username}
            onChange={form.handleChange}
            onBlur={form.handleBlur}
            style={{
              width: '100%',
              padding: '8px',
              fontSize: '14px',
              border:
                form.errors.username && form.touched.username
                  ? '2px solid #dc3545'
                  : '1px solid #ccc',
              borderRadius: '4px',
            }}
          />
          {form.errors.username && form.touched.username && (
            <div
              style={{ color: '#dc3545', fontSize: '12px', marginTop: '5px' }}
            >
              {form.errors.username}
            </div>
          )}
        </div>

        {/* Email */}
        <div style={{ marginBottom: '15px' }}>
          <label
            style={{
              display: 'block',
              marginBottom: '5px',
              fontWeight: 'bold',
            }}
          >
            Email:
          </label>
          <input
            type='email'
            name='email'
            value={form.values.email}
            onChange={form.handleChange}
            onBlur={form.handleBlur}
            style={{
              width: '100%',
              padding: '8px',
              fontSize: '14px',
              border:
                form.errors.email && form.touched.email
                  ? '2px solid #dc3545'
                  : '1px solid #ccc',
              borderRadius: '4px',
            }}
          />
          {form.errors.email && form.touched.email && (
            <div
              style={{ color: '#dc3545', fontSize: '12px', marginTop: '5px' }}
            >
              {form.errors.email}
            </div>
          )}
        </div>

        {/* Password */}
        <div style={{ marginBottom: '15px' }}>
          <label
            style={{
              display: 'block',
              marginBottom: '5px',
              fontWeight: 'bold',
            }}
          >
            Password:
          </label>
          <input
            type='password'
            name='password'
            value={form.values.password}
            onChange={form.handleChange}
            onBlur={form.handleBlur}
            style={{
              width: '100%',
              padding: '8px',
              fontSize: '14px',
              border:
                form.errors.password && form.touched.password
                  ? '2px solid #dc3545'
                  : '1px solid #ccc',
              borderRadius: '4px',
            }}
          />
          {form.errors.password && form.touched.password && (
            <div
              style={{ color: '#dc3545', fontSize: '12px', marginTop: '5px' }}
            >
              {form.errors.password}
            </div>
          )}
        </div>

        {/* Confirm Password */}
        <div style={{ marginBottom: '20px' }}>
          <label
            style={{
              display: 'block',
              marginBottom: '5px',
              fontWeight: 'bold',
            }}
          >
            Confirm Password:
          </label>
          <input
            type='password'
            name='confirmPassword'
            value={form.values.confirmPassword}
            onChange={form.handleChange}
            onBlur={form.handleBlur}
            style={{
              width: '100%',
              padding: '8px',
              fontSize: '14px',
              border:
                form.errors.confirmPassword && form.touched.confirmPassword
                  ? '2px solid #dc3545'
                  : '1px solid #ccc',
              borderRadius: '4px',
            }}
          />
          {form.errors.confirmPassword && form.touched.confirmPassword && (
            <div
              style={{ color: '#dc3545', fontSize: '12px', marginTop: '5px' }}
            >
              {form.errors.confirmPassword}
            </div>
          )}
        </div>

        {/* Actions */}
        <div style={{ display: 'flex', gap: '10px' }}>
          <button
            type='submit'
            disabled={form.isSubmitting || !form.isValid}
            style={{
              flex: 1,
              padding: '10px',
              backgroundColor: form.isValid ? '#28a745' : '#6c757d',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor:
                form.isValid && !form.isSubmitting ? 'pointer' : 'not-allowed',
              fontSize: '16px',
            }}
          >
            {form.isSubmitting ? 'Submitting...' : 'Register'}
          </button>

          <button
            type='button'
            onClick={form.reset}
            style={{
              padding: '10px 20px',
              backgroundColor: '#6c757d',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            Reset
          </button>
        </div>
      </form>

      {/* Debug */}
      <details style={{ marginTop: '20px' }}>
        <summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
          Debug Info
        </summary>
        <pre
          style={{
            marginTop: '10px',
            padding: '10px',
            backgroundColor: '#f5f5f5',
            borderRadius: '4px',
            fontSize: '12px',
            overflow: 'auto',
          }}
        >
          {JSON.stringify(
            {
              values: form.values,
              errors: form.errors,
              touched: form.touched,
              isValid: form.isValid,
              isSubmitting: form.isSubmitting,
            },
            null,
            2,
          )}
        </pre>
      </details>
    </div>
  );
}

// 🧪 Testing Checklist:
// - [ ] Validation works on blur
// - [ ] Errors clear when typing
// - [ ] Submit validates all fields
// - [ ] Can't submit invalid form
// - [ ] Reset clears everything
// - [ ] Async submit works
💡 Solution
jsx
/**
 * Custom hook quản lý form với validation, touched state và submit handling
 * @param {Object} initialValues - Giá trị ban đầu của các field
 * @param {Function} validate - hàm validate toàn bộ form, trả về object lỗi { field: message }
 * @param {Function} onSubmit - hàm xử lý khi form hợp lệ (nhận values)
 * @returns {{
 *   values: Object,
 *   errors: Object,
 *   touched: Object,
 *   handleChange: (e: Event) => void,
 *   handleBlur: (e: Event) => void,
 *   handleSubmit: (e?: Event) => Promise<void>,
 *   reset: () => void,
 *   isValid: boolean,
 *   isSubmitting: boolean
 * }}
 */
function useForm(initialValues, validate, onSubmit) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = useCallback(
    (event) => {
      const { name, value } = event.target;
      setValues((prev) => ({ ...prev, [name]: value }));

      // Xóa lỗi ngay khi người dùng bắt đầu sửa
      if (errors[name]) {
        setErrors((prev) => ({ ...prev, [name]: undefined }));
      }
    },
    [errors],
  );

  const handleBlur = useCallback(
    (event) => {
      const { name } = event.target;

      setTouched((prev) => ({ ...prev, [name]: true }));

      // Validate field khi blur (nếu có validate)
      if (validate) {
        const fieldErrors = validate(values);
        setErrors((prev) => ({
          ...prev,
          [name]: fieldErrors[name],
        }));
      }
    },
    [values, validate],
  );

  const handleSubmit = useCallback(
    async (event) => {
      if (event) event.preventDefault();

      // Đánh dấu tất cả field là touched để hiển thị lỗi
      const allTouched = Object.keys(values).reduce((acc, key) => {
        acc[key] = true;
        return acc;
      }, {});
      setTouched(allTouched);

      // Validate toàn bộ form
      const validationErrors = validate ? validate(values) : {};
      setErrors(validationErrors);

      // Nếu không có lỗi → submit
      if (Object.keys(validationErrors).length === 0) {
        setIsSubmitting(true);
        try {
          await onSubmit(values);
        } catch (error) {
          console.error('Submit error:', error);
          // Có thể set error chung nếu muốn
        } finally {
          setIsSubmitting(false);
        }
      }
    },
    [values, validate, onSubmit],
  );

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);

  const isValid = Object.keys(errors).length === 0;

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    isValid,
    isSubmitting,
  };
}

// Ví dụ kết quả khi sử dụng:
// const form = useForm(
//   { username: '', email: '', password: '' },
//   (values) => {
//     const errors = {};
//     if (!values.username) errors.username = 'Required';
//     if (!values.email) errors.email = 'Required';
//     if (!values.password) errors.password = 'Required';
//     return errors;
//   },
//   async (values) => { console.log('Submitted:', values); }
// );

// → form.values.username thay đổi khi gõ
// → form.errors.username xuất hiện khi blur nếu rỗng
// → form.handleSubmit() → validate toàn bộ → chỉ gọi onSubmit nếu hợp lệ
// → form.isValid = false khi có lỗi
// → form.isSubmitting = true trong lúc submit async
// → form.reset() → trở về initialValues, xóa lỗi & touched

⭐⭐⭐⭐⭐ Exercise 5: useInfiniteScroll Hook (90 phút)

jsx
/**
 * 🎯 Mục tiêu: Production-ready infinite scroll hook
 * ⏱️ Thời gian: 90 phút
 *
 * 📋 Feature Specification:
 *
 * Build hook cho infinite scroll lists:
 * 1. Intersection Observer based
 * 2. Loading states
 * 3. Error handling with retry
 * 4. Bidirectional scroll (load more top/bottom)
 * 5. Reset functionality
 * 6. Custom threshold
 *
 * 🏗️ Technical Design Doc:
 *
 * 1. Hook Architecture:
 *    - useRef cho sentinel element
 *    - useState cho loading/error states
 *    - useCallback cho loadMore function
 *    - Intersection Observer setup
 *
 * 2. API Design:
 *    const {
 *      items,
 *      loading,
 *      error,
 *      hasMore,
 *      sentinelRef,
 *      loadMore,
 *      retry,
 *      reset
 *    } = useInfiniteScroll(fetchFunction, options);
 *
 * ✅ Production Checklist:
 * - [ ] Intersection Observer setup
 * - [ ] Proper cleanup
 * - [ ] Error handling
 * - [ ] Loading states
 * - [ ] Has more detection
 * - [ ] Retry functionality
 * - [ ] Reset works
 * - [ ] No memory leaks
 */

import { useState, useRef, useEffect, useCallback } from 'react';

function useInfiniteScroll(fetchFunction, options = {}) {
  const {
    threshold = 0.5,
    rootMargin = '0px',
    initialPage = 1,
    enabled = true,
  } = options;

  const [items, setItems] = useState([]);
  const [page, setPage] = useState(initialPage);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);

  const sentinelRef = useRef(null);
  const observerRef = useRef(null);

  // Load more items
  const loadMore = useCallback(async () => {
    if (loading || !hasMore || !enabled) return;

    setLoading(true);
    setError(null);

    try {
      const result = await fetchFunction(page);

      setItems((prev) => [...prev, ...result.items]);
      setHasMore(result.hasMore);
      setPage((prev) => prev + 1);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }, [fetchFunction, page, loading, hasMore, enabled]);

  // Retry after error
  const retry = useCallback(() => {
    setError(null);
    loadMore();
  }, [loadMore]);

  // Reset to initial state
  const reset = useCallback(() => {
    setItems([]);
    setPage(initialPage);
    setLoading(false);
    setError(null);
    setHasMore(true);
  }, [initialPage]);

  // Setup Intersection Observer
  useEffect(() => {
    if (!enabled || !sentinelRef.current) return;

    const observer = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
        if (entry.isIntersecting && !loading && hasMore) {
          loadMore();
        }
      },
      {
        threshold,
        rootMargin,
      },
    );

    observer.observe(sentinelRef.current);
    observerRef.current = observer;

    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [enabled, loading, hasMore, loadMore, threshold, rootMargin]);

  return {
    items,
    loading,
    error,
    hasMore,
    sentinelRef,
    loadMore,
    retry,
    reset,
  };
}

// Demo: Posts List
function InfiniteScrollDemo() {
  // Mock fetch function
  const fetchPosts = async (page) => {
    await new Promise((resolve) => setTimeout(resolve, 1000));

    const itemsPerPage = 10;
    const totalPages = 5;

    const items = Array.from({ length: itemsPerPage }, (_, i) => ({
      id: (page - 1) * itemsPerPage + i + 1,
      title: `Post #${(page - 1) * itemsPerPage + i + 1}`,
      content: `This is the content of post ${(page - 1) * itemsPerPage + i + 1}`,
    }));

    return {
      items,
      hasMore: page < totalPages,
    };
  };

  const { items, loading, error, hasMore, sentinelRef, retry, reset } =
    useInfiniteScroll(fetchPosts);

  return (
    <div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          marginBottom: '20px',
        }}
      >
        <h2>useInfiniteScroll Demo</h2>
        <button onClick={reset}>Reset</button>
      </div>

      {/* Items list */}
      <div>
        {items.map((item) => (
          <div
            key={item.id}
            style={{
              padding: '15px',
              marginBottom: '10px',
              backgroundColor: 'white',
              border: '1px solid #ddd',
              borderRadius: '4px',
            }}
          >
            <h3 style={{ margin: '0 0 5px 0' }}>{item.title}</h3>
            <p style={{ margin: 0, color: '#666' }}>{item.content}</p>
          </div>
        ))}
      </div>

      {/* Loading indicator */}
      {loading && (
        <div
          style={{
            padding: '20px',
            textAlign: 'center',
            color: '#007bff',
          }}
        >
          Loading more posts...
        </div>
      )}

      {/* Error */}
      {error && (
        <div
          style={{
            padding: '15px',
            backgroundColor: '#fee',
            border: '1px solid #fcc',
            borderRadius: '4px',
            textAlign: 'center',
          }}
        >
          <p style={{ margin: '0 0 10px 0', color: '#c00' }}>
            Error: {error.message}
          </p>
          <button onClick={retry}>Retry</button>
        </div>
      )}

      {/* Sentinel element */}
      {hasMore && !error && (
        <div
          ref={sentinelRef}
          style={{
            height: '20px',
            margin: '10px 0',
          }}
        />
      )}

      {/* End message */}
      {!hasMore && (
        <div
          style={{
            padding: '20px',
            textAlign: 'center',
            color: '#999',
            borderTop: '2px solid #eee',
          }}
        >
          🎉 You've reached the end!
        </div>
      )}

      {/* Stats */}
      <div
        style={{
          position: 'fixed',
          bottom: '20px',
          right: '20px',
          padding: '10px',
          backgroundColor: 'rgba(0,0,0,0.8)',
          color: 'white',
          borderRadius: '4px',
          fontSize: '12px',
        }}
      >
        <div>Items loaded: {items.length}</div>
        <div>Loading: {loading ? 'Yes' : 'No'}</div>
        <div>Has more: {hasMore ? 'Yes' : 'No'}</div>
      </div>
    </div>
  );
}

// 📝 Implementation Notes:
//
// Hook can be extended with:
// 1. Prefetching (load next page in advance)
// 2. Caching (don't refetch same pages)
// 3. Bidirectional scroll (load top + bottom)
// 4. Virtual scrolling integration
// 5. Search/filter support
💡 Solution
jsx
/**
 * Custom hook triển khai infinite scroll dựa trên Intersection Observer
 * Tự động load thêm data khi sentinel element xuất hiện trong viewport
 * @param {Function} fetchFunction - async function nhận page number, trả về { items: [], hasMore: boolean }
 * @param {Object} [options={}] - tùy chọn cấu hình
 * @param {number} [options.threshold=0.1] - ngưỡng intersection (0-1)
 * @param {string} [options.rootMargin='0px'] - margin cho observer
 * @param {number} [options.initialPage=1] - trang bắt đầu
 * @param {boolean} [options.enabled=true] - bật/tắt infinite scroll
 * @returns {{
 *   items: any[],
 *   loading: boolean,
 *   error: Error | null,
 *   hasMore: boolean,
 *   sentinelRef: React.RefObject<HTMLDivElement>,
 *   loadMore: () => Promise<void>,
 *   retry: () => Promise<void>,
 *   reset: () => void
 * }}
 */
function useInfiniteScroll(fetchFunction, options = {}) {
  const {
    threshold = 0.1,
    rootMargin = '0px',
    initialPage = 1,
    enabled = true,
  } = options;

  const [items, setItems] = useState([]);
  const [page, setPage] = useState(initialPage);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);

  const sentinelRef = useRef(null);
  const observerRef = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore || !enabled) return;

    setLoading(true);
    setError(null);

    try {
      const result = await fetchFunction(page);
      setItems((prev) => [...prev, ...result.items]);
      setHasMore(result.hasMore);
      setPage((prev) => prev + 1);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }, [fetchFunction, page, loading, hasMore, enabled]);

  const retry = useCallback(() => {
    setError(null);
    loadMore();
  }, [loadMore]);

  const reset = useCallback(() => {
    setItems([]);
    setPage(initialPage);
    setLoading(false);
    setError(null);
    setHasMore(true);
  }, [initialPage]);

  // Thiết lập Intersection Observer
  useEffect(() => {
    if (!enabled || !sentinelRef.current) return;

    const observer = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
        if (entry.isIntersecting && !loading && hasMore) {
          loadMore();
        }
      },
      { threshold, rootMargin },
    );

    observer.observe(sentinelRef.current);
    observerRef.current = observer;

    // Cleanup
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [enabled, loading, hasMore, loadMore, threshold, rootMargin]);

  // Load trang đầu tiên nếu enabled
  useEffect(() => {
    if (enabled && page === initialPage && items.length === 0) {
      loadMore();
    }
  }, [enabled, initialPage, items.length, loadMore]);

  return {
    items,
    loading,
    error,
    hasMore,
    sentinelRef,
    loadMore,
    retry,
    reset,
  };
}

// Ví dụ kết quả khi sử dụng:
// const fetchPosts = async (page) => {
//   await new Promise(r => setTimeout(r, 800));
//   const newItems = Array.from({length: 5}, (_, i) => ({
//     id: (page-1)*5 + i + 1,
//     title: `Post ${(page-1)*5 + i + 1}`
//   }));
//   return {
//     items: newItems,
//     hasMore: page < 4
//   };
// };

// const { items, loading, hasMore, sentinelRef } = useInfiniteScroll(fetchPosts);

// → Cuộn xuống → khi thấy sentinel → tự động gọi loadMore
// → items tăng dần: 5 → 10 → 15 → 20 (hasMore = false)
// → loading hiển thị khi đang fetch
// → error xuất hiện nếu fetch thất bại → có thể retry()
// → reset() → xóa items, quay về page 1, load lại từ đầu

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

Rules of Hooks

jsx
// ✅ RULES - BẮT BUỘC tuân thủ:

// Rule 1: Only call hooks at the TOP LEVEL
function MyComponent() {
  const [state, setState] = useState(0); // ✅ Top level

  if (condition) {
    const [bad, setBad] = useState(0); // ❌ Inside condition
  }

  for (let i = 0; i < 10; i++) {
    const [bad, setBad] = useState(i); // ❌ Inside loop
  }

  function nested() {
    const [bad, setBad] = useState(0); // ❌ Inside nested function
  }

  return <div>{state}</div>;
}

// Rule 2: Only call hooks from REACT FUNCTIONS
function MyComponent() {
  const value = useMyHook(); // ✅ React component
  return <div>{value}</div>;
}

function useMyHook() {
  const value = useOtherHook(); // ✅ Custom hook
  return value;
}

function regularFunction() {
  const value = useMyHook(); // ❌ Regular JS function
  return value;
}

// Rule 3: Custom hooks must start with "use"
function useMyHook() {
  // ✅ Starts with "use"
  const [state, setState] = useState(0);
  return state;
}

function myHook() {
  // ❌ Doesn't start with "use"
  const [state, setState] = useState(0); // ESLint error!
  return state;
}

When to Create Custom Hook

Decision Tree:
────────────────────────────────────────
Logic được dùng ở >1 component?

    ├─ No → Keep inline

    └─ Yes

        Logic có dùng hooks?

            ├─ No → Regular function

            └─ Yes → Custom hook!

Examples:

jsx
// ❌ Không cần custom hook
function formatDate(date) {
  return date.toLocaleDateString();
}
// → Regular function đủ

// ✅ Cần custom hook
function useFormattedDate(date) {
  const [formatted, setFormatted] = useState('');

  useEffect(() => {
    setFormatted(date.toLocaleDateString());
  }, [date]);

  return formatted;
}
// → Uses hooks, needs to be custom hook

Hook Composition Patterns

Pattern 1: Hook sử dụng Hook khác

jsx
// useDebounce uses useState + useEffect
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// useSearch uses useDebounce
function useSearch(query) {
  const [results, setResults] = useState([]);
  const debouncedQuery = useDebounce(query, 500); // ✅ Compose!

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`)
        .then((res) => res.json())
        .then(setResults);
    }
  }, [debouncedQuery]);

  return results;
}

Pattern 2: Hook Aggregation

jsx
// Combine multiple hooks into one API
function useAuth() {
  const user = useUser();
  const login = useLogin();
  const logout = useLogout();
  const permissions = usePermissions();

  return {
    user,
    login,
    logout,
    permissions,
    isAuthenticated: !!user,
    can: (action) => permissions.includes(action),
  };
}

// Clean usage
function ProtectedPage() {
  const { user, isAuthenticated, can } = useAuth();

  if (!isAuthenticated) return <Login />;
  if (!can('view-page')) return <Forbidden />;

  return <div>Welcome, {user.name}!</div>;
}

Pattern 3: Hook with Options Object

jsx
// Flexible API with options
import { useState, useEffect, useCallback, useRef } from 'react';

export function useFetch(url, options = {}) {
  const {
    method = 'GET',
    headers = {},
    body,
    dependencies = [],
    lazy = false,
    onSuccess,
    onError,
  } = options;

  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(!lazy);
  const [error, setError] = useState(null);

  const abortRef = useRef(null);

  const execute = useCallback(
    async (override = {}) => {
      // 🔥 Hủy request cũ nếu có
      if (abortRef.current) {
        abortRef.current.abort();
      }

      const controller = new AbortController();
      abortRef.current = controller;

      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url, {
          method: override.method ?? method,
          headers: override.headers ?? headers,
          body: override.body ?? body,
          signal: controller.signal,
        });

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

        const contentType = response.headers.get('content-type');

        let result;
        if (contentType && contentType.includes('application/json')) {
          result = await response.json();
        } else {
          result = await response.text();
        }

        setData(result);
        onSuccess?.(result);

        return result;
      } catch (err) {
        if (err.name === 'AbortError') return;

        setError(err);
        onError?.(err);

        throw err;
      } finally {
        setLoading(false);
      }
    },
    [url, method, headers, body, onSuccess, onError],
  );

  // 🔄 Auto fetch
  useEffect(() => {
    if (!lazy) {
      execute();
    }

    return () => {
      abortRef.current?.abort(); // 🧹 cleanup
    };
  }, [execute, lazy, ...dependencies]);

  // 🔁 Retry
  const retry = useCallback(() => {
    execute();
  }, [execute]);

  // ♻️ Reset state
  const reset = useCallback(() => {
    abortRef.current?.abort();
    setData(null);
    setError(null);
    setLoading(false);
  }, []);

  return {
    data,
    loading,
    error,
    refetch: execute,
    retry,
    reset,
  };
}

// Usage:
// 🟢 1. GET (auto fetch): Gọi API ngay khi component mount
function UserList() {
  const { data, loading, error } = useFetch('/api/users');

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error</p>;

  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// 🔵 2. GET với dependency (refetch khi param đổi)
function UserDetail({ userId }) {
  const { data, loading } = useFetch(`/api/users/${userId}`, {
    dependencies: [userId],
  });

  return <div>{loading ? 'Loading...' : data?.name}</div>;
}

// 🟡 3. POST (không body) Dùng để trigger action
function SendEmail() {
  const { refetch, loading } = useFetch('/api/send-email', {
    method: 'POST',
    lazy: true,
  });

  return (
    <button onClick={refetch}>{loading ? 'Sending...' : 'Send Email'}</button>
  );
}

// 🔴 4. POST có body (cách cơ bản)
function CreateUser() {
  const [name, setName] = useState('');

  const { refetch, loading } = useFetch('/api/users', {
    method: 'POST',
    lazy: true,
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ name }),
  });

  return (
    <>
      <input onChange={(e) => setName(e.target.value)} />
      <button onClick={refetch}>{loading ? 'Creating...' : 'Create'}</button>
    </>
  );
}

// 🔥 5. POST có body (chuẩn nhất – dùng override)
function CreateUser() {
  const [name, setName] = useState('');

  const { refetch, loading } = useFetch('/api/users', {
    method: 'POST',
    lazy: true,
    headers: {
      'Content-Type': 'application/json',
    },
  });

  const handleSubmit = () => {
    refetch({
      body: JSON.stringify({ name }), // 🔥 luôn mới
    });
  };

  return (
    <>
      <input onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit}>
        {loading ? 'Creating...' : 'Create'}
      </button>
    </>
  );
}

//🔁 6. Retry (gọi lại request cũ)
function UserList() {
  const { data, error, retry } = useFetch('/api/users');

  if (error) {
    return (
      <div>
        <p>Error xảy ra</p>
        <button onClick={retry}>Thử lại</button>
      </div>
    );
  }

  return <div>{JSON.stringify(data)}</div>;
}

//🚀 7. Retry + override (🔥 xịn nhất)

function CreateUser() {
  const [name, setName] = useState('');

  const { error, refetch } = useFetch('/api/users', {
    method: 'POST',
    lazy: true,
    headers: {
      'Content-Type': 'application/json',
    },
  });

  const handleRetry = () => {
    refetch({
      body: JSON.stringify({ name: name + '_retry' }),
    });
  };

  return (
    <div>
      <input onChange={(e) => setName(e.target.value)} />

      <button onClick={() => refetch({ body: JSON.stringify({ name }) })}>
        Submit
      </button>

      {error && <button onClick={handleRetry}>Retry với data mới</button>}
    </div>
  );
}

// ♻️ 8. Reset state

const { data, reset } = useFetch('/api/users');

<button onClick={reset}>Clear</button>;

Return Value Patterns

jsx
// Pattern 1: Array (useState-like)
function useToggle(initial) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue((v) => !v);
  return [value, toggle]; // ✅ Array destructuring
}
const [isOpen, toggleOpen] = useToggle(false);

// Pattern 2: Object (more descriptive)
function useCounter(initial) {
  const [count, setCount] = useState(initial);
  return {
    count,
    increment: () => setCount((c) => c + 1),
    decrement: () => setCount((c) => c - 1),
    reset: () => setCount(initial),
  };
}
const { count, increment } = useCounter(0);

// Pattern 3: Mixed (complex hooks)
function useForm(initialValues) {
  // ... implementation
  return {
    values,
    errors,
    handleChange,
    handleSubmit,
    // Also expose as tuple for convenience
    field: (name) => ({
      name,
      value: values[name],
      onChange: handleChange,
      error: errors[name],
    }),
  };
}

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

Bug 1: Violating Rules of Hooks ⭐

jsx
// ❌ BUG: Conditional hook call
function BuggyConditionalHook({ shouldFetch }) {
  if (shouldFetch) {
    const [data, setData] = useState(null); // ⚠️ Conditional hook!

    useEffect(() => {
      fetch('/api/data')
        .then((res) => res.json())
        .then(setData);
    }, []);

    return <div>{data}</div>;
  }

  return <div>Not fetching</div>;
}

🔍 Debug Questions:

  1. Tại sao đây là violation?
  2. Điều gì sẽ xảy ra?
  3. Cách fix?

💡 Giải thích:

jsx
// ❌ VẤN ĐỀ:
// React relies on hook call order to maintain state
// Timeline:
// Render 1 (shouldFetch=true):  Hook #1 = useState, Hook #2 = useEffect
// Render 2 (shouldFetch=false): No hooks called!
// React confused: Where did hooks go? 💥

// ✅ SOLUTION: Always call hooks, control behavior
function FixedConditionalHook({ shouldFetch }) {
  const [data, setData] = useState(null); // ✅ Always called

  useEffect(() => {
    if (shouldFetch) {
      // ✅ Condition inside effect
      fetch('/api/data')
        .then((res) => res.json())
        .then(setData);
    }
  }, [shouldFetch]);

  if (!shouldFetch) {
    return <div>Not fetching</div>;
  }

  return <div>{data}</div>;
}

// 📊 RULE:
// Hooks must be called in the SAME ORDER every render
// → Never put hooks inside conditions, loops, or nested functions

Bug 2: Stale Closure in Custom Hook ⭐⭐

jsx
// ❌ BUG: Callback has stale closure
function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay); // ⚠️ Stale callback!
    return () => clearInterval(id);
  }, [delay]); // ⚠️ Missing callback dependency
}

// Usage:
function Counter() {
  const [count, setCount] = useState(0);

  useInterval(() => {
    console.log(count); // ⚠️ Always logs 0!
    setCount(count + 1); // ⚠️ Always sets to 1!
  }, 1000);

  return <div>{count}</div>;
}

🔍 Debug Questions:

  1. Tại sao count luôn là 0?
  2. ESLint warning là gì?
  3. Fix như thế nào?

💡 Giải thích:

jsx
// ❌ VẤN ĐỀ:
// - useEffect runs once (delay dependency only)
// - Callback captures count = 0 (initial value)
// - Interval keeps calling callback với count = 0
// - setCount(0 + 1) → always 1
// - State updates nhưng callback không re-created

// ✅ SOLUTION 1: Add callback to dependencies (not ideal)
function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay);
    return () => clearInterval(id);
  }, [callback, delay]); // ⚠️ Re-creates interval mỗi lần callback changes
}

// ✅ SOLUTION 2: Ref pattern (recommended)
function useInterval(callback, delay) {
  const callbackRef = useRef(callback);

  // Keep ref updated
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    const id = setInterval(() => {
      callbackRef.current(); // ✅ Always calls latest callback
    }, delay);

    return () => clearInterval(id);
  }, [delay]); // ✅ Only depends on delay
}

// Usage now works:
function Counter() {
  const [count, setCount] = useState(0);

  useInterval(() => {
    console.log(count); // ✅ Logs current count!
    setCount((c) => c + 1); // ✅ Better: functional update
  }, 1000);

  return <div>{count}</div>;
}

// 📊 LESSON:
// When hook accepts callbacks, use ref pattern to avoid stale closures

Bug 3: Hook Dependency Missing ⭐⭐⭐

jsx
// ❌ BUG: Missing dependency causes bugs
function useSearch(query) {
  const [results, setResults] = useState([]);
  const apiKey = 'abc123';

  useEffect(() => {
    fetch(`/api/search?q=${query}&key=${apiKey}`)
      .then((res) => res.json())
      .then(setResults);
  }, [query]); // ⚠️ Missing apiKey dependency

  return results;
}

// Later, apiKey becomes dynamic:
function SearchComponent() {
  const [apiKey, setApiKey] = useState('abc123');
  const [query, setQuery] = useState('');
  const results = useSearch(query); // ⚠️ apiKey not passed!

  // ...
}

🔍 Debug Questions:

  1. Tại sao ESLint warning?
  2. Điều gì xảy ra khi apiKey changes?
  3. Best practice?

💡 Giải thích:

jsx
// ❌ VẤN ĐỀ:
// - apiKey used trong effect
// - Không có trong dependencies
// - Effect không re-run khi apiKey changes
// - Stale apiKey used!

// ✅ SOLUTION 1: Add all dependencies
function useSearch(query, apiKey) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetch(`/api/search?q=${query}&key=${apiKey}`)
      .then((res) => res.json())
      .then(setResults);
  }, [query, apiKey]); // ✅ All dependencies

  return results;
}

// ✅ SOLUTION 2: Move constant outside
const API_KEY = 'abc123'; // ✅ Outside component

function useSearch(query) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetch(`/api/search?q=${query}&key=${API_KEY}`)
      .then((res) => res.json())
      .then(setResults);
  }, [query]); // ✅ API_KEY không cần dependency (constant)

  return results;
}

// 📊 RULE:
// ALWAYS include ALL values from component scope used inside effect
// ESLint plugin "exhaustive-deps" helps catch these

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

Knowledge Check

Đánh dấu các câu bạn có thể trả lời tự tin:

  • [ ] Custom hook là gì?
  • [ ] Rules of Hooks là gì và tại sao quan trọng?
  • [ ] Khi nào nên tạo custom hook?
  • [ ] Custom hook có share state giữa components không?
  • [ ] Naming convention cho custom hooks?
  • [ ] Custom hook có thể return gì?
  • [ ] Làm sao compose multiple hooks?
  • [ ] Stale closure trong hooks là gì?
  • [ ] Dependency array rules?
  • [ ] Test custom hooks như thế nào?

Code Review Checklist

Khi review custom hooks, check:

✅ Naming & Structure:

  • [ ] Name starts with "use"
  • [ ] Only called from React functions
  • [ ] Called at top level (no conditions/loops)
  • [ ] Clear, descriptive name

✅ Dependencies:

  • [ ] All dependencies included
  • [ ] No unnecessary dependencies
  • [ ] ESLint exhaustive-deps satisfied
  • [ ] Ref pattern cho callbacks if needed

✅ API Design:

  • [ ] Return value intuitive
  • [ ] Options object cho flexibility
  • [ ] Reasonable defaults
  • [ ] TypeScript types (if applicable)

✅ Edge Cases:

  • [ ] Cleanup trong useEffect
  • [ ] Handle unmount during async
  • [ ] Error handling
  • [ ] Loading states

✅ Reusability:

  • [ ] Generic enough
  • [ ] Không hardcode values
  • [ ] Configurable via props/options
  • [ ] Well documented

❌ Red Flags:

  • [ ] Violates Rules of Hooks
  • [ ] Missing dependencies
  • [ ] No cleanup
  • [ ] Over-complicated
  • [ ] Too specific (not reusable)

🏠 BÀI TẬP VỀ NHÀ

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

Exercise: useTimeout Hook

jsx
/**
 * 🎯 Mục tiêu: Declarative setTimeout hook
 *
 * Requirements:
 * 1. Execute callback after delay
 * 2. Auto-cleanup on unmount
 * 3. Reset functionality
 * 4. Cancel functionality
 *
 * API:
 * const { reset, cancel } = useTimeout(callback, delay);
 */

function useTimeout(callback, delay) {
  // TODO: Implement
  // Hints:
  // - useRef cho timeout ID
  // - useRef cho callback (avoid stale closure)
  // - useEffect cho setup/cleanup
  // - Return reset & cancel functions
}

// Usage:
function NotificationDemo() {
  const [show, setShow] = useState(true);

  const { reset, cancel } = useTimeout(() => {
    setShow(false);
  }, 3000);

  return (
    <div>
      {show && (
        <div>
          This will disappear in 3 seconds
          <button onClick={cancel}>Keep it</button>
          <button onClick={reset}>Reset timer</button>
        </div>
      )}
    </div>
  );
}
💡 Solution
jsx
/**
 * Custom hook quản lý setTimeout một cách declarative
 * Tự động cleanup khi unmount hoặc delay thay đổi
 * @param {Function} callback - hàm sẽ chạy sau delay
 * @param {number} delay - thời gian chờ (ms), nếu null thì không chạy
 * @returns {{
 *   reset: () => void,
 *   cancel: () => void
 * }}
 */
function useTimeout(callback, delay) {
  const timeoutRef = useRef(null);
  const callbackRef = useRef(callback);

  // Luôn giữ callback mới nhất để tránh stale closure
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // Setup và cleanup timeout
  useEffect(() => {
    // Nếu delay là null/undefined → không làm gì
    if (delay == null) {
      return;
    }

    // Clear timeout cũ nếu có
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    // Set timeout mới
    timeoutRef.current = setTimeout(() => {
      callbackRef.current();
    }, delay);

    // Cleanup khi unmount hoặc delay thay đổi
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, [delay]); // Chỉ re-run khi delay thay đổi

  const reset = useCallback(() => {
    if (delay == null) return;

    // Clear và set lại timeout mới
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    timeoutRef.current = setTimeout(() => {
      callbackRef.current();
    }, delay);
  }, [delay]);

  const cancel = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
  }, []);

  return { reset, cancel };
}

// Ví dụ kết quả khi sử dụng:
// const [show, setShow] = useState(true);
// const { reset, cancel } = useTimeout(() => setShow(false), 3000);

// → Sau 3 giây → show = false
// → cancel() → ngăn thông báo biến mất
// → reset() → đếm lại từ đầu 3 giây
// → Nếu component unmount → timeout tự động clear, không gọi callback
// → Nếu delay thay đổi (ví dụ setDelay(5000)) → timeout cũ clear, set mới

Nâng cao (60 phút)

Exercise: usePrevious Hook

jsx
/**
 * 🎯 Mục tiêu: Track previous value của state/prop
 *
 * Scenario:
 * Component cần so sánh current value với previous value.
 *
 * Requirements:
 * 1. Return previous value
 * 2. Update on value change
 * 3. Initial value handling
 *
 * Bonus:
 * - usePreviousDistinct (only update if different)
 * - usePreviousArray (track multiple previous values)
 */

function usePrevious(value) {
  // TODO: Implement
  // Pattern: useRef + useEffect
}

// Usage:
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  const diff = count - (prevCount ?? 0);

  return (
    <div>
      <p>Current: {count}</p>
      <p>Previous: {prevCount}</p>
      <p>Change: {diff > 0 ? `+${diff}` : diff}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}
💡 Solution
jsx
/**
 * Custom hook theo dõi giá trị trước đó của một state hoặc prop
 * @param {any} value - Giá trị hiện tại cần theo dõi previous
 * @returns {any | undefined} Giá trị ở render trước đó (undefined ở lần render đầu)
 */
function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  // Trả về giá trị cũ (trước khi effect chạy)
  // → ở lần render đầu: undefined
  // → từ lần thứ 2 trở đi: giá trị của render trước
  return ref.current;
}

// Bonus: Phiên bản chỉ cập nhật khi giá trị thực sự thay đổi (khác với previous)
function usePreviousDistinct(value, isEqual = Object.is) {
  const ref = useRef();

  useEffect(() => {
    if (!isEqual(value, ref.current)) {
      ref.current = value;
    }
  }, [value, isEqual]);

  return ref.current;
}

// Ví dụ kết quả khi sử dụng:
// function Counter() {
//   const [count, setCount] = useState(0);
//   const prevCount = usePrevious(count);
//
//   return (
//     <div>
//       <p>Current: {count}</p>
//       <p>Previous: {prevCount ?? '—'}</p>
//       <p>{prevCount !== undefined && count > prevCount ? '↑ tăng' : '↓ giảm'}</p>
//       <button onClick={() => setCount(c => c + 1)}>Tăng</button>
//     </div>
//   );
// }
//
// Render 1: count = 0, prevCount = undefined
// Render 2: count = 1, prevCount = 0
// Render 3: count = 2, prevCount = 1
// → Dễ dàng so sánh current vs previous trong cùng render

📚 TÀI LIỆU THAM KHẢO

Bắt buộc đọc

  1. React Docs - Reusing Logic with Custom Hooks:https://react.dev/learn/reusing-logic-with-custom-hooks

  2. React Docs - Rules of Hooks:https://react.dev/reference/rules/rules-of-hooks

Đọc thêm

  1. useHooks - Collection of Custom Hooks:https://usehooks.com/

  2. React Hook Form:https://react-hook-form.com/

  3. SWR - Data Fetching Hook:https://swr.vercel.app/


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

Kiến thức nền (cần biết từ trước)

  • Ngày 11-14: useState patterns
  • Ngày 16-20: useEffect và dependencies
  • Ngày 21-22: useRef patterns
  • Ngày 23: useLayoutEffect timing

Hướng tới (sẽ dùng ở)

  • Ngày 25: Project - combine tất cả hooks
  • Ngày 29-34: Advanced patterns
  • Real apps: Reusable logic everywhere!

💡 SENIOR INSIGHTS

Cân Nhắc Production

1. TypeScript Support

typescript
// ✅ GOOD: Type-safe custom hook
function useLocalStorage<T>(
  key: string,
  initialValue: T,
): [T, (value: T | ((prev: T) => T)) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = (value: T | ((prev: T) => T)) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

2. Testing Custom Hooks

jsx
// Use @testing-library/react-hooks
import { renderHook, act } from '@testing-library/react-hooks';

test('useCounter increments', () => {
  const { result } = renderHook(() => useCounter(0));

  expect(result.current.count).toBe(0);

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

3. Performance Optimization

jsx
// ✅ GOOD: Memoize expensive calculations
function useExpensiveHook(data) {
  const processedData = useMemo(() => {
    return expensiveComputation(data);
  }, [data]);

  const callback = useCallback(() => {
    doSomething(processedData);
  }, [processedData]);

  return { processedData, callback };
}

Câu Hỏi Phỏng Vấn

Junior Level:

Q1: "Custom hook là gì?"

Expected answer:

  • Function sử dụng React hooks
  • Tên bắt đầu bằng "use"
  • Extract và reuse stateful logic
  • Không share state giữa components

Q2: "Rules of Hooks?"

Expected answer:

  • Only call at top level
  • Only call from React functions
  • Name must start with "use"

Mid Level:

Q3: "Làm sao avoid stale closure trong custom hook?"

Expected answer:

jsx
const callbackRef = useRef(callback);

useEffect(() => {
  callbackRef.current = callback;
}, [callback]);

useEffect(() => {
  // Use callbackRef.current
}, []);

Q4: "Khi nào dùng array vs object return?"

Expected answer:

  • Array: Simple hooks (useState-like), allow rename
  • Object: Complex hooks, descriptive names
  • Consider usage patterns

Senior Level:

Q5: "Design generic data fetching hook."

Expected answer:

  • Generic over resource type
  • Caching strategy
  • Request cancellation
  • Error retry logic
  • TypeScript generics
  • Configurable options

Q6: "Handle race conditions trong async hook?"

Expected answer:

  • AbortController
  • Ignore outdated responses
  • Request ID tracking
  • Cleanup on unmount

War Stories

Story: The Infinite Loop Hook

Production bug: Custom hook caused infinite re-renders.

jsx
// ❌ BUG:
function useData(url) {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(url)
      .then((res) => res.json())
      .then(setData); // ⚠️ setData creates new function reference!
  }, [setData]); // ⚠️ Infinite loop!

  return data;
}

// ✅ FIX:
function useData(url) {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(url)
      .then((res) => res.json())
      .then(setData);
  }, [url]); // ✅ Only depend on url

  return data;
}

Lesson: setState functions are stable, don't need dependencies!


🎯 PREVIEW NGÀY MAI

Ngày 25: Project - Real-time Dashboard 📊

Ngày mai chúng ta sẽ build complete project combining tất cả hooks đã học!

Project features:

  • Real-time data updates
  • Custom hooks for logic reuse
  • Advanced patterns
  • Production-ready code
  • Complete dashboard app

Get ready to put everything together! 🚀


✅ CHECKLIST HOÀN THÀNH

Trước khi kết thúc ngày học, check:

  • [ ] Hiểu sâu custom hooks concept
  • [ ] Làm đủ 5 exercises
  • [ ] Đọc React docs về custom hooks
  • [ ] Làm bài tập về nhà
  • [ ] Review Rules of Hooks
  • [ ] Chuẩn bị cho project day

🎉 Congratulations! Bạn đã hoàn thành Ngày 24!

Bạn đã học được: ✅ Custom hooks fundamentals ✅ Rules of Hooks ✅ Hook composition ✅ Common patterns (useToggle, useLocalStorage, useDebounce) ✅ Testing strategies ✅ Production considerations

Tomorrow: Build a complete real-world project! 💪

Custom Hooks — Tham chiếu tư duy cho Senior

Ngày 24 | Extract, Reuse & Compose Hook Logic
Custom hook = function bình thường + gọi được React hooks bên trong.


MỤC LỤC

  1. Bản chất — Custom Hook là gì
  2. Rules of Hooks — Không được phá vỡ
  3. Khi nào tạo custom hook
  4. Return type — Array hay Object?
  5. Các patterns phổ biến
  6. Hook Composition
  7. Stale closure trong custom hooks
  8. Dạng bài tập & nhận dạng vấn đề
  9. Anti-patterns cần tránh
  10. Interview Questions — Theo level
  11. War Stories
  12. Decision Framework nhanh

1. Bản chất

Custom Hook là gì

Chỉ là một JavaScript function bình thường với hai đặc điểm:

  1. Tên bắt đầu bằng use (convention, không phải magic)
  2. Bên trong có thể gọi các React hooks khác

Không có gì đặc biệt về cú pháp — React nhận ra custom hook thông qua prefix use để áp dụng linting rules.

Custom hook KHÔNG share state

Đây là hiểu lầm phổ biến nhất:

ComponentA gọi useCounter() → instance state riêng: count = 0
ComponentB gọi useCounter() → instance state riêng: count = 0

ComponentA increment → ComponentA count = 1
ComponentB count = 0 (không thay đổi)

Mỗi lần component gọi custom hook → React tạo ra một bộ state độc lập cho component đó. Custom hook chỉ share logic, không share state.

So sánh Custom Hook vs HOC/Render Props (legacy)

Custom HookHOCRender Props
Cú phápĐơn giản, functionWrapper componentCallback prop
DebugDễ (flat)Wrapper hellCallback hell
TypeScriptDễ typePhức tạpPhức tạp
ComposeDễ dàngKhóKhó
Share stateKhôngKhôngKhông

Custom hooks là câu trả lời hiện đại cho bài toán reuse logic — thay thế hoàn toàn HOC và Render Props trong hầu hết trường hợp.


2. Rules of Hooks

3 Rules không được phá vỡ

Rule 1: Chỉ gọi hooks ở top level

  • Không trong if/else, không trong for loop, không trong nested function
  • Lý do: React dựa vào thứ tự gọi hooks để map đúng state với hook. Nếu conditional → thứ tự thay đổi → sai state

Rule 2: Chỉ gọi hooks trong React functions

  • Trong function component hoặc trong custom hook khác
  • Không trong regular functions, class methods, event handlers bên ngoài component

Rule 3: Tên bắt đầu bằng use

  • Convention để linter (eslint-plugin-react-hooks) nhận ra và kiểm tra
  • Không có use → linter không báo warning khi vi phạm rule 1 & 2

Tại sao thứ tự hooks quan trọng

React lưu state theo thứ tự gọi, không theo tên. Mỗi render, React expects cùng số hooks theo cùng thứ tự. Nếu hooks trong if/else → số hooks thay đổi → React map nhầm state.


3. Khi nào tạo Custom Hook

NÊN tạo custom hook khi:

  • Logic lặp lại ở 2+ components và logic đủ phức tạp
  • Logic phức tạp trong component làm khó đọc render logic
  • Logic cần test riêng độc lập với UI
  • Logic có lifecycle (setup + cleanup) cần quản lý nhất quán

KHÔNG nên tạo custom hook khi:

  • Logic chỉ dùng 1 lần và đơn giản → inline là đủ
  • Chỉ là helper function không dùng hooks → regular function
  • Extract quá sớm — chờ pattern xuất hiện ≥2 lần trước khi abstract
  • Tạo chỉ để "cảm giác clean" mà không có lợi ích thực sự

Heuristic thực dụng

"Extract khi bạn cần giải thích cùng logic ở 2 nơi khác nhau. Đừng extract sớm."


4. Return type

Return Array — Khi nào?

Dùng khi hook đơn giản, tương tự useState, và muốn người dùng tự đặt tên:

const [isOpen, toggle] = useToggle()
const [isDark, { setTrue: enableDark, setFalse: disableDark }] = useToggle()

Ưu điểm: Flexible renaming, destructuring ngắn gọn
Nhược điểm: Phải nhớ thứ tự, không tự documenting

Return Object — Khi nào?

Dùng khi hook phức tạp, trả về nhiều giá trị, và tên mang ý nghĩa:

const { data, loading, error, refetch } = useFetch(url)
const { count, increment, decrement, reset } = useCounter(0)

Ưu điểm: Self-documenting, dễ pick chỉ những gì cần, không cần nhớ thứ tự
Nhược điểm: Verbose hơn một chút

Nguyên tắc chọn

  • ≤ 2 giá trị, simple → array (như useState)
  • ≥ 3 giá trị hoặc cần tên rõ ràng → object
  • Khi không chắc → object (safer, clearer)

5. Patterns phổ biến

useToggle

Extract boolean state với toggle/setTrue/setFalse — dùng khắp nơi: modal open/close, accordion, dark mode.

Return: [value, { toggle, setTrue, setFalse }] — array + object lồng nhau để vừa rename được vừa có named actions.


useLocalStorage

Sync state với localStorage — persist across sessions.

Cơ chế:

  1. Lazy init useState: Đọc từ localStorage khi mount (dùng lazy init để tránh đọc mỗi render)
  2. Setter: Cập nhật cả state lẫn localStorage cùng lúc
  3. Error handling: try/catch vì localStorage có thể bị block (private mode, full storage)
  4. Hỗ trợ functional update giống useState

Gotcha: localStorage chỉ store strings → cần JSON.stringify/parse. Lỗi parse (corrupted data) → fallback về initialValue.


useDebounce

Delay updating một value cho đến khi ngừng thay đổi trong X ms.

Cơ chế:

  • State debouncedValue khởi tạo bằng value
  • useEffect([value, delay]): Set timeout, cleanup clear timeout
  • Mỗi lần value thay đổi → cleanup cancel timeout cũ → set timeout mới
  • Chỉ khi ngừng thay đổi đủ delay ms → timeout fire → cập nhật debouncedValue

Use case kinh điển: Search input — chỉ fetch sau khi user ngừng gõ 300–500ms.


useFetch / useData

Data fetching với Loading/Error/Success states — pattern đã học ở Ngày 19–20, nhưng giờ được extract thành hook để reuse.

Cơ chế: Wrap toàn bộ fetch logic (AbortController, isCancelled, 3-state) vào hook, expose { data, loading, error, refetch }.

Bổ sung trong hook: refetch function — bằng cách expose một setter hoặc dùng refreshKey state trong deps để trigger lại effect.


useOnlineStatus

Track navigator.onLine với event listeners.

Cơ chế: useState(navigator.onLine)useEffect add online/offline listeners → cleanup remove listeners → return boolean.

Đây là ví dụ điển hình cho "sync với external system" — lý do tồn tại của useEffect.


usePrevious

Track giá trị của render trước — dùng useRef + useEffect pattern từ Ngày 21.

Cơ chế:

  1. ref = useRef()
  2. Return ref.current trong render (giá trị cũ — chưa bị update)
  3. useEffect([value]): ref.current = value — cập nhật sau khi render dùng xong giá trị cũ

useInterval / useTimeout

Wrapper an toàn cho setInterval/setTimeout với auto-cleanup và stable callback.

Trick quan trọng trong useInterval:
Callback có thể stale. Giải pháp: Lưu callback vào ref, cập nhật ref mỗi render. Interval callback đọc từ ref → luôn có callback mới nhất mà không cần recreate interval.


6. Hook Composition

Custom hooks có thể gọi custom hooks khác — đây là sức mạnh lớn nhất.

useDebouncedSearch
  └── useDebounce (delay query)
  └── useFetch (fetch với debounced query)
      └── useAbortController (cancel request)

Nguyên tắc: Mỗi hook một trách nhiệm. Compose từ nhỏ lên lớn.

Ví dụ thực tế:

  • useForm = useLocalStorage (persist) + useDebounce (auto-save) + validation logic
  • useInfiniteScroll = useFetch + useIntersectionObserver + pagination state
  • useRealTimeData = useWebSocket + useFetch (initial load) + state merging

7. Stale Closure

Vấn đề trong custom hooks

Hook nhận callback như parameter, lưu trong effect với empty deps [] → callback bị stale sau re-render.

Ví dụ: useInterval(callback, 1000) — nếu callback được capture lúc mount, mọi invocation sau đó dùng callback cũ.

Giải pháp: Callback Ref Pattern

// Trong custom hook nhận callback:
const callbackRef = useRef(callback)

useEffect(() => {
  callbackRef.current = callback   // Cập nhật ref mỗi render
})

useEffect(() => {
  const id = setInterval(() => {
    callbackRef.current()          // Luôn gọi callback mới nhất
  }, delay)
  return () => clearInterval(id)
}, [delay])                        // Chỉ recreate khi delay thay đổi

Tại sao pattern này hoạt động: Interval được tạo một lần (stable), nhưng mỗi tick đọc callback từ ref — luôn là version mới nhất. Tách bạch "khi nào recreate interval" và "callback nào sẽ gọi".


8. Dạng bài tập

DẠNG 1 — Logic fetch lặp lại ở nhiều components

Nhận dạng: Copy-paste loading/error/success pattern ở 3+ components
Hướng giải: Extract useFetch(url) → return { data, loading, error }

DẠNG 2 — Toggle logic phức tạp

Nhận dạng: const [isOpen, setIsOpen] = useState(false) + setIsOpen(!isOpen) lặp nhiều chỗ
Hướng giải: useToggle(initial)[value, { toggle, setTrue, setFalse }]

DẠNG 3 — State cần persist qua sessions

Nhận dạng: Cần lưu user preference, theme, form draft vào localStorage
Hướng giải: useLocalStorage(key, defaultValue) — API giống useState

DẠNG 4 — Search input fetch quá nhiều

Nhận dạng: Mỗi keystroke gọi API → quá nhiều requests
Hướng giải: useDebounce(value, delay) → chỉ fetch khi ngừng gõ

DẠNG 5 — Window resize/scroll cần track

Nhận dạng: addEventListener trong nhiều components, quên cleanup
Hướng giải: useWindowSize() hoặc useScrollPosition() — encapsulate listener + cleanup

DẠNG 6 — Custom hook tạo infinite loop

Nhận dạng: Component re-render liên tục, "Maximum update depth exceeded"
Nguyên nhân phổ biến: Object/function trong deps được tạo mới mỗi render; hoặc đặt setState function vào deps (không cần thiết — setState là stable)
Hướng giải: Dùng primitive values trong deps, loại bỏ stable references khỏi deps

DẠNG 7 — Callback truyền vào custom hook bị stale

Nhận dạng: Hook nhận callback, callback dùng state mới nhưng luôn đọc giá trị cũ
Hướng giải: Callback ref pattern — lưu callback vào ref, cập nhật mỗi render

DẠNG 8 — Hook vi phạm Rules of Hooks

Nhận dạng: Hook được gọi trong if, for, hay trong nested function
Hướng giải: Di chuyển ra top level; nếu cần điều kiện → đặt điều kiện bên trong hook (hook luôn gọi, nhưng logic bên trong có thể conditional)

DẠNG 9 — Nhầm lẫn shared state

Nhận dạng: Nghĩ 2 components dùng cùng hook sẽ sync state với nhau
Hướng giải: Hiểu rõ: hook share logic, không share state. Để share state → cần Context (Ngày sau) hoặc lift state lên component cha

DẠNG 10 — Extract hook quá sớm (premature abstraction)

Nhận dạng: Custom hook chỉ dùng 1 lần, không phức tạp hơn inline
Hướng giải: Chờ pattern xuất hiện 2+ lần trước khi extract; inline khi logic đơn giản


9. Anti-patterns cần tránh

❌ Không bắt đầu tên bằng use

Triệu chứng: Linter không báo warning khi vi phạm Rules of Hooks → bugs tiềm ẩn
Fix: Luôn prefix use

❌ Hooks trong điều kiện hoặc loop

Triệu chứng: "Rendered more hooks than previous render" error
Fix: Hooks ở top level; logic conditional đặt bên trong hook

❌ Đặt setState function vào deps

Triệu chứng: ESLint exhaustive-deps warning, dễ infinite loop nếu không để ý
Fix: setState và dispatch từ useState/useReducer là stable — không cần trong deps

❌ Nhầm lẫn shared state

Triệu chứng: Expect state sync giữa components nhưng không hoạt động
Fix: Custom hook không share state. Dùng Context hoặc lift state nếu cần share

❌ Extract quá sớm (over-abstraction)

Triệu chứng: Hooks phức tạp hơn code inline, khó đọc hơn
Fix: Extract khi thực sự cần (DRY ≥ 2 lần, hoặc logic quá phức tạp cho component)

❌ Stale callback trong hooks với timers/intervals

Triệu chứng: Callback dùng giá trị cũ sau re-render
Fix: Callback ref pattern

❌ Thiếu cleanup trong hooks tạo resources

Triệu chứng: Memory leaks, listeners tích lũy
Fix: Luôn return cleanup function khi hook tạo timers/listeners/subscriptions


10. Interview Questions

Junior Level

Q: Custom hook là gì?
A: Function bắt đầu bằng use, có thể gọi React hooks bên trong. Dùng để extract và reuse stateful logic giữa components. Không có gì đặc biệt về cú pháp — chỉ là naming convention.

Q: Rules of Hooks là gì?
A: (1) Chỉ gọi ở top level — không trong if/loop/nested function. (2) Chỉ gọi trong React function components hoặc custom hooks. (3) Tên bắt đầu bằng use. React dựa vào thứ tự gọi để track state đúng.

Q: Custom hook có share state giữa components không?
A: Không. Mỗi component gọi hook → tạo instance state độc lập. Hook share logic, không share state. Giống như 2 lần gọi useState — mỗi lần là một state riêng.


Mid Level

Q: Khi nào dùng array return vs object return?
A: Array khi hook đơn giản (≤2 values), muốn người dùng tự rename như useState. Object khi hook phức tạp, nhiều values, cần tên có ý nghĩa rõ ràng. Không chắc → dùng object (safer).

Q: Làm sao avoid stale closure trong custom hook nhận callback?
A: Callback ref pattern: lưu callback vào useRef, cập nhật ref mỗi render trong useEffect. Long-lived callback (timer, WebSocket) đọc từ callbackRef.current thay vì closure — luôn gọi version mới nhất mà không cần recreate.

Q: Tại sao setState function không cần đặt vào deps?
A: React đảm bảo setState và dispatch (từ useReducer) là stable references — không bao giờ thay đổi giữa các renders. Đặt vào deps vừa thừa vừa gây nhầm lẫn.


Senior Level

Q: Design generic data fetching hook cho production.
A: Cần xem xét: (1) Generic type (TypeScript <T>). (2) AbortController cho race conditions. (3) refetch function để trigger lại. (4) enabled option để lazy fetch. (5) Configurable error handling và retry. (6) Transform option cho data normalization. (7) Cache strategy — đây là lý do libraries như React Query/SWR tồn tại — implement đầy đủ rất phức tạp.

Q: Handle race conditions trong async custom hook.
A: Hai approaches: (1) isLatest flag trong closure — cleanup đặt false, chỉ setState khi còn true. (2) AbortController — pass signal vào fetch, cleanup abort. AbortController tốt hơn vì cancel thực sự request ở network level. Cần handle AbortError riêng để không treat là real error.

Q: Custom hook vs HOC — khi nào dùng cái nào?
A: Custom hook gần như luôn tốt hơn HOC trong modern React: đơn giản hơn, TypeScript friendly hơn, không tạo extra DOM node, dễ debug hơn. HOC vẫn phù hợp khi cần inject props vào component mà không modify component (ví dụ: third-party components), hoặc khi làm việc với class components.


11. War Stories

Story: setState trong deps → Infinite Loop

Custom hook useData(url) với useEffect(..., [setData]). Vấn đề: ESLint yêu cầu khai báo đủ deps, dev thêm setData vào deps mà không biết setData là stable. Nhưng phiên bản React cũ hơn hoặc một số edge cases — belief là setData mới tạo mỗi render → effect chạy mỗi render → loop. Fix: Chỉ [url] trong deps — setState là stable, không cần khai báo. Lesson: Hiểu rõ cái gì là stable (setState, dispatch) và cái gì không (objects, functions định nghĩa trong component).


Story: Over-abstracted Hook Library

Team tạo custom hook cho mọi thứ — useButtonClick, useInputValue, useDivStyle... 50+ hooks chỉ wrap vài dòng code. Kết quả: Mọi người phải tra cứu hook nào làm gì thay vì đọc code trực tiếp. Maintenance hell. Fix: Delete 80% hooks, giữ lại những hooks thực sự phức tạp và tái sử dụng. Lesson: Custom hooks là công cụ — dùng khi cần, không dùng vì muốn "clean".


Story: Hook Không Cleanup Listeners

useKeyboardShortcut(key, callback) — popular hook dùng khắp app. Bug: Listeners tích lũy sau mỗi re-render vì hook không cleanup. Sau vài phút dùng, mỗi shortcut bị gọi hàng chục lần. Fix: Return removeEventListener trong cleanup. Lesson: Custom hooks tạo resources = phải cleanup. Review mọi hook có addEventListener, setInterval, subscriptions.


12. Decision Framework nhanh

Có nên tạo custom hook không?

Logic này dùng React hooks không?
├── Không → Regular function là đủ
└── Có → Logic xuất hiện ở bao nhiêu components?
    ├── 1 → Inline trong component (chờ cần thêm)
    └── ≥ 2 → Extract custom hook ✅
              └── Logic đủ phức tạp để justify không?
                  ├── Không (< 5 dòng đơn giản) → Cân nhắc inline
                  └── Có → Extract ✅

Array hay Object return?

Số values trả về?
├── 1-2 và muốn rename linh hoạt → Array [value, setter]
└── ≥ 3 hoặc cần tên rõ ràng → Object { value, action1, action2 }

Cần Callback Ref Pattern không?

Hook nhận callback từ ngoài?
└── Callback được dùng trong effect/timer/interval?
    └── Effect có empty deps hoặc deps không chứa callback?
        └── Có → Dùng Callback Ref Pattern
                 Ref lưu callback → cập nhật mỗi render → timer đọc từ ref

Cleanup cần không?

Hook tạo: timer, listener, subscription, connection, observer?
└── Có → BẮT BUỘC return cleanup function

Tổng hợp từ Ngày 24: Custom Hooks — Basics & Reusable Logic

Personal tech knowledge base