📅 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:
useState, useEffect, useRef là gì?
- Built-in React hooks để manage state, side effects, và refs
Làm sao reuse logic giữa nhiều components?
- Trước đây: HOCs, render props. Bây giờ: Custom hooks! 🎯
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:
// ❌ 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 everywhereVấ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
// ✅ 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 effectsVisualization:
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!
└─ useTimeout1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "Custom hooks là special syntax"
// ❌ 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"
// ❌ 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
// 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"
// 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 ⭐
/**
* 🎯 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:
// 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 behaviorDemo 2: Kịch Bản Thực Tế - useLocalStorage ⭐⭐
/**
* 🎯 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:
// 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 ⭐⭐⭐
/**
* 🎯 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:
// 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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
// ✅ 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:
// ❌ 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 hookHook Composition Patterns
Pattern 1: Hook sử dụng Hook khác
// 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
// 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
// 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
// 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 ⭐
// ❌ 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:
- Tại sao đây là violation?
- Điều gì sẽ xảy ra?
- Cách fix?
💡 Giải thích:
// ❌ 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 functionsBug 2: Stale Closure in Custom Hook ⭐⭐
// ❌ 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:
- Tại sao count luôn là 0?
- ESLint warning là gì?
- Fix như thế nào?
💡 Giải thích:
// ❌ 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 closuresBug 3: Hook Dependency Missing ⭐⭐⭐
// ❌ 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:
- Tại sao ESLint warning?
- Điều gì xảy ra khi apiKey changes?
- Best practice?
💡 Giải thích:
// ❌ 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
/**
* 🎯 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
/**
* 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ớiNâng cao (60 phút)
Exercise: usePrevious Hook
/**
* 🎯 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
/**
* 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
React Docs - Reusing Logic with Custom Hooks:https://react.dev/learn/reusing-logic-with-custom-hooks
React Docs - Rules of Hooks:https://react.dev/reference/rules/rules-of-hooks
Đọc thêm
useHooks - Collection of Custom Hooks:https://usehooks.com/
React Hook Form:https://react-hook-form.com/
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
// ✅ 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
// 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
// ✅ 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:
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.
// ❌ 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
- Bản chất — Custom Hook là gì
- Rules of Hooks — Không được phá vỡ
- Khi nào tạo custom hook
- Return type — Array hay Object?
- Các patterns phổ biến
- Hook Composition
- Stale closure trong custom hooks
- Dạng bài tập & nhận dạng vấn đề
- Anti-patterns cần tránh
- Interview Questions — Theo level
- War Stories
- 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:
- Tên bắt đầu bằng
use(convention, không phải magic) - 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 Hook | HOC | Render Props | |
|---|---|---|---|
| Cú pháp | Đơn giản, function | Wrapper component | Callback prop |
| Debug | Dễ (flat) | Wrapper hell | Callback hell |
| TypeScript | Dễ type | Phức tạp | Phức tạp |
| Compose | Dễ dàng | Khó | Khó |
| Share state | Không | Không | Khô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 trongforloop, 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ế:
- Lazy init
useState: Đọc từ localStorage khi mount (dùng lazy init để tránh đọc mỗi render) - Setter: Cập nhật cả state lẫn localStorage cùng lúc
- Error handling:
try/catchvì localStorage có thể bị block (private mode, full storage) - 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
debouncedValuekhởi tạo bằngvalue 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 đủ
delayms → 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ế:
ref = useRef()- Return
ref.currenttrong render (giá trị cũ — chưa bị update) 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 logicuseInfiniteScroll=useFetch+useIntersectionObserver+ pagination stateuseRealTimeData=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 đổiTạ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ừ refCleanup cần không?
Hook tạo: timer, listener, subscription, connection, observer?
└── Có → BẮT BUỘC return cleanup functionTổng hợp từ Ngày 24: Custom Hooks — Basics & Reusable Logic