📅 NGÀY 29: Custom Hooks với useReducer - Reusable Logic Patterns
📍 Vị trí: Phase 3, Tuần 6, Ngày 29/45
⏱️ Thời lượng: 3-4 giờ
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Tạo được custom hooks encapsulate useReducer logic
- [ ] Implement được useFetch, useAsync, useForm hooks
- [ ] Compose được multiple hooks together
- [ ] Share được logic across components without duplication
- [ ] Test được custom hooks (conceptual understanding)
🤔 Kiểm tra đầu vào (5 phút)
Trước khi bắt đầu, hãy trả lời 3 câu hỏi sau:
Khi nào nên extract logic thành custom hook?
- Gợi ý: Code được dùng ở 2+ components, logic phức tạp...
Custom hook khác function thông thường như thế nào?
- Gợi ý: Naming convention, có thể dùng hooks bên trong...
Bạn đã gặp trường hợp copy-paste logic giữa components chưa?
- Ví dụ: Fetch user data ở nhiều nơi, form handling duplicate...
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Hãy tưởng tượng bạn có 3 components đều fetch data từ API:
// ❌ VẤN ĐỀ: Duplicate Logic Everywhere
// Component 1: UserProfile
function UserProfile({ userId }) {
const [state, dispatch] = useReducer(dataReducer, initialState);
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
}
};
fetchUser();
return () => controller.abort();
}, [userId]);
// ... render
}
// Component 2: PostList
function PostList() {
const [state, dispatch] = useReducer(dataReducer, initialState);
useEffect(() => {
const controller = new AbortController();
const fetchPosts = async () => {
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch('/api/posts', {
signal: controller.signal,
});
const data = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
}
};
fetchPosts();
return () => controller.abort();
}, []);
// ... render
}
// Component 3: CommentList
function CommentList({ postId }) {
const [state, dispatch] = useReducer(dataReducer, initialState);
useEffect(() => {
const controller = new AbortController();
const fetchComments = async () => {
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch(`/api/posts/${postId}/comments`, {
signal: controller.signal,
});
const data = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
}
};
fetchComments();
return () => controller.abort();
}, [postId]);
// ... render
}Vấn đề:
- 🔴 90% code giống nhau → Copy-paste nightmare
- 🔴 Bug fix phải sửa 3 chỗ → Easy to miss
- 🔴 Add feature (retry) phải update everywhere → Unmaintainable
- 🔴 Hard to test → Test 3 components riêng biệt
- 🔴 No single source of truth → Inconsistent behavior
1.2 Giải Pháp: Custom Hook
Custom Hook = Function extract reusable logic
// ✅ GIẢI PHÁP: useFetch Hook
function useFetch(url) {
const initialState = {
data: null,
loading: false,
error: null,
};
const [state, dispatch] = useReducer(dataReducer, initialState);
useEffect(() => {
if (!url) return;
const controller = new AbortController();
const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
}
};
fetchData();
return () => controller.abort();
}, [url]);
return state;
}
// ✅ USAGE: Clean & Consistent
function UserProfile({ userId }) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{data?.name}</div>;
}
function PostList() {
const { data, loading, error } = useFetch('/api/posts');
// ... render
}
function CommentList({ postId }) {
const { data, loading, error } = useFetch(`/api/posts/${postId}/comments`);
// ... render
}Lợi ích:
- ✅ DRY (Don't Repeat Yourself) → Logic ở 1 chỗ
- ✅ Easy to maintain → Bug fix/feature add 1 lần
- ✅ Testable → Test hook độc lập
- ✅ Reusable → Dùng ở unlimited components
- ✅ Consistent → Same behavior everywhere
1.3 Mental Model
┌───────────────────────────────────────────────┐
│ CUSTOM HOOK (Logic Container) │
│ │
│ function useFetch(url) { │
│ const [state, dispatch] = useReducer(...) │
│ useEffect(() => { ... }, [url]) │
│ return { data, loading, error } │
│ } │
│ │
│ ✅ Encapsulates: │
│ • State management (useReducer) │
│ • Side effects (useEffect) │
│ • Cleanup logic │
│ • Error handling │
└───────────────────────────────────────────────┘
↓ ↓ ↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Component A │ │ Component B │ │ Component C │
│ │ │ │ │ │
│ useFetch(A) │ │ useFetch(B) │ │ useFetch(C) │
│ │ │ │ │ │
│ Render UI │ │ Render UI │ │ Render UI │
└─────────────┘ └─────────────┘ └─────────────┘Analogy: Custom Hook giống Recipe (công thức nấu ăn)
- Recipe: Định nghĩa steps (logic)
- Cook: Components dùng recipe
- Ingredients: Parameters (url, userId, etc.)
- Result: Cooked dish (data, loading, error)
Nhiều cooks có thể dùng cùng recipe → Consistent dishes!
1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "Custom hook chỉ là rename function"
- ✅ Sự thật: Custom hook CÓ THỂ dùng hooks (useState, useEffect, etc.). Regular function KHÔNG THỂ!
❌ Hiểu lầm 2: "Custom hook phải bắt đầu bằng 'use'"
- ✅ Sự thật: ĐÂY LÀ QUY TẮC BẮT BUỘC! React dựa vào naming để check Rules of Hooks
❌ Hiểu lầm 3: "Custom hooks share state giữa components"
- ✅ Sự thật: Mỗi component gọi hook có STATE RIÊNG. Hook chỉ share LOGIC, không share state!
❌ Hiểu lầm 4: "Nên tạo custom hook cho mọi thứ"
- ✅ Sự thật: Chỉ extract khi có reuse hoặc logic phức tạp. Premature abstraction = bad!
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: useFetch - Basic Data Fetching Hook ⭐
import { useReducer, useEffect } from 'react';
// 🏭 REDUCER
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { data: null, loading: true, error: null };
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null };
case 'FETCH_ERROR':
return { data: null, loading: false, error: action.payload };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 🎯 CUSTOM HOOK: useFetch
function useFetch(url, options = {}) {
const initialState = {
data: null,
loading: false,
error: null,
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
// ⚠️ Skip nếu no URL
if (!url) return;
const controller = new AbortController();
const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
if (error.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}
};
fetchData();
// Cleanup
return () => controller.abort();
}, [url]); // Re-fetch khi URL changes
return state;
}
// ✅ USAGE EXAMPLES
// Example 1: Simple fetch
function UserList() {
const { data, loading, error } = useFetch(
'https://jsonplaceholder.typicode.com/users',
);
if (loading) return <div>Loading users...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// Example 2: Dynamic URL
function UserProfile({ userId }) {
const { data, loading, error } = useFetch(
userId ? `https://jsonplaceholder.typicode.com/users/${userId}` : null,
);
if (!userId) return <div>Select a user</div>;
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h2>{data?.name}</h2>
<p>{data?.email}</p>
</div>
);
}
// Example 3: POST request
function CreatePost() {
const [title, setTitle] = useState('');
const [shouldPost, setShouldPost] = useState(false);
const { data, loading, error } = useFetch(
shouldPost ? 'https://jsonplaceholder.typicode.com/posts' : null,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, userId: 1 }),
},
);
const handleSubmit = (e) => {
e.preventDefault();
setShouldPost(true);
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button
type='submit'
disabled={loading}
>
{loading ? 'Creating...' : 'Create Post'}
</button>
{error && <div>Error: {error}</div>}
{data && <div>Created post #{data.id}</div>}
</form>
);
}🎯 Key Points:
Hook Naming:
- MUST start with
use(React rule) - Descriptive name:
useFetch, NOTgetData
- MUST start with
Parameters:
- Accept
urlandoptions - Flexible for different use cases
- Accept
Return Value:
- Return state object:
{ data, loading, error } - Destructure in component
- Return state object:
Each Component = Separate State:
UserListcó state riêngUserProfilecó state riêng- Hook chỉ share logic!
Demo 2: useAsync - Generic Async Hook ⭐⭐
// 🎯 CUSTOM HOOK: useAsync (More flexible than useFetch)
function useAsync(asyncFunction, immediate = true) {
const initialState = {
data: null,
loading: immediate, // Start loading nếu immediate
error: null,
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
// ✅ Execute function manually
const execute = async (...params) => {
dispatch({ type: 'FETCH_START' });
try {
const data = await asyncFunction(...params);
dispatch({ type: 'FETCH_SUCCESS', payload: data });
return data;
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
throw error;
}
};
// ✅ Auto-execute on mount nếu immediate = true
useEffect(() => {
if (immediate) {
execute();
}
}, []); // Empty deps = run once
return { ...state, execute };
}
// ✅ USAGE EXAMPLES
// Example 1: Auto-execute on mount
function UserList() {
const fetchUsers = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/users');
return res.json();
};
const { data, loading, error } = useAsync(fetchUsers, true);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// Example 2: Manual execution (button click)
function CreateUser() {
const [name, setName] = useState('');
const createUser = async (userName) => {
const res = await fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: userName }),
});
return res.json();
};
const { data, loading, error, execute } = useAsync(createUser, false);
const handleSubmit = async (e) => {
e.preventDefault();
try {
await execute(name);
setName(''); // Clear input on success
} catch (err) {
// Error already handled by hook
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button
type='submit'
disabled={loading}
>
{loading ? 'Creating...' : 'Create'}
</button>
{error && <div style={{ color: 'red' }}>{error}</div>}
{data && <div style={{ color: 'green' }}>Created: {data.name}</div>}
</form>
);
}
// Example 3: Retry functionality
function DataWithRetry() {
const fetchData = async () => {
// Simulate random failure
if (Math.random() > 0.5) {
throw new Error('Random failure');
}
const res = await fetch('https://jsonplaceholder.typicode.com/posts/1');
return res.json();
};
const { data, loading, error, execute } = useAsync(fetchData, true);
return (
<div>
{loading && <div>Loading...</div>}
{error && (
<div>
<div style={{ color: 'red' }}>Error: {error}</div>
<button onClick={execute}>Retry</button>
</div>
)}
{data && (
<div>
<h3>{data.title}</h3>
<p>{data.body}</p>
</div>
)}
</div>
);
}🎯 useAsync vs useFetch:
| Feature | useFetch | useAsync |
|---|---|---|
| URL-specific | ✅ Yes | ❌ No |
| Any async fn | ❌ No | ✅ Yes |
| Manual trigger | ❌ Auto | ✅ execute() |
| Flexibility | ⚠️ Medium | ✅ High |
Demo 3: useForm - Form Management Hook ⭐⭐⭐
// 🏭 FORM REDUCER
function formReducer(state, action) {
switch (action.type) {
case 'UPDATE_FIELD':
return {
...state,
values: {
...state.values,
[action.payload.name]: action.payload.value,
},
touched: {
...state.touched,
[action.payload.name]: true,
},
// Clear error khi user edits
errors: {
...state.errors,
[action.payload.name]: undefined,
},
};
case 'SET_ERRORS':
return {
...state,
errors: action.payload.errors,
};
case 'SET_SUBMITTING':
return {
...state,
isSubmitting: action.payload.isSubmitting,
};
case 'RESET_FORM':
return action.payload.initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 🎯 CUSTOM HOOK: useForm
function useForm(initialValues, validate, onSubmit) {
const initialState = {
values: initialValues,
errors: {},
touched: {},
isSubmitting: false,
};
const [state, dispatch] = useReducer(formReducer, initialState);
// ✅ Handle field change
const handleChange = (e) => {
const { name, value } = e.target;
dispatch({
type: 'UPDATE_FIELD',
payload: { name, value },
});
};
// ✅ Handle blur (mark as touched)
const handleBlur = (e) => {
const { name } = e.target;
dispatch({
type: 'UPDATE_FIELD',
payload: { name, value: state.values[name] },
});
};
// ✅ Handle submit
const handleSubmit = async (e) => {
e.preventDefault();
// Validate
const validationErrors = validate(state.values);
if (Object.keys(validationErrors).length > 0) {
dispatch({
type: 'SET_ERRORS',
payload: { errors: validationErrors },
});
return;
}
// Submit
dispatch({ type: 'SET_SUBMITTING', payload: { isSubmitting: true } });
try {
await onSubmit(state.values);
// Reset form on success
dispatch({ type: 'RESET_FORM', payload: { initialState } });
} catch (error) {
dispatch({
type: 'SET_ERRORS',
payload: { errors: { submit: error.message } },
});
} finally {
dispatch({ type: 'SET_SUBMITTING', payload: { isSubmitting: false } });
}
};
// ✅ Reset form manually
const reset = () => {
dispatch({ type: 'RESET_FORM', payload: { initialState } });
};
return {
values: state.values,
errors: state.errors,
touched: state.touched,
isSubmitting: state.isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
};
}
// ✅ USAGE EXAMPLE
function RegistrationForm() {
// Initial values
const initialValues = {
username: '',
email: '',
password: '',
};
// Validation function
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 (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = 'Email is invalid';
}
if (!values.password) {
errors.password = 'Password is required';
} else if (values.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}
return errors;
};
// Submit handler
const onSubmit = async (values) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log('Form submitted:', values);
alert('Registration successful!');
};
// ✅ Use the hook
const {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
} = useForm(initialValues, validate, onSubmit);
return (
<form onSubmit={handleSubmit}>
{/* Username */}
<div>
<label>Username:</label>
<input
type='text'
name='username'
value={values.username}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.username && errors.username && (
<span style={{ color: 'red' }}>{errors.username}</span>
)}
</div>
{/* Email */}
<div>
<label>Email:</label>
<input
type='email'
name='email'
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.email && errors.email && (
<span style={{ color: 'red' }}>{errors.email}</span>
)}
</div>
{/* Password */}
<div>
<label>Password:</label>
<input
type='password'
name='password'
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.password && errors.password && (
<span style={{ color: 'red' }}>{errors.password}</span>
)}
</div>
{/* Submit error */}
{errors.submit && <div style={{ color: 'red' }}>{errors.submit}</div>}
{/* Buttons */}
<button
type='submit'
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Register'}
</button>
<button
type='button'
onClick={reset}
>
Reset
</button>
</form>
);
}🎯 useForm Benefits:
- Reusable: Dùng cho bất kỳ form nào
- Flexible: Pass custom validate, onSubmit
- Complete: Handles values, errors, touched, submitting
- Clean components: Form logic extracted
🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Level 1: Áp Dụng Concept (15 phút)
/**
* 🎯 Mục tiêu: Create useToggle custom hook
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: Context, useMemo, useCallback
*
* Requirements:
* 1. Hook quản lý boolean state (true/false)
* 2. Provide: value, toggle(), setTrue(), setFalse()
* 3. Optional initial value (default false)
*
* Usage:
* const { value, toggle, setTrue, setFalse } = useToggle(false);
*
* 💡 Gợi ý:
* - Dùng useState (simple case, không cần useReducer)
* - Return object với 4 properties
* - Memoize functions? NO - chưa học useCallback!
*/
// TODO: Implement useToggle hook
// Starter:
function useToggle(initialValue = false) {
// TODO: Implement
}
// Test component:
function ToggleDemo() {
const modal = useToggle(false);
const sidebar = useToggle(true);
return (
<div>
<button onClick={modal.toggle}>Toggle Modal</button>
<button onClick={modal.setTrue}>Open Modal</button>
<button onClick={modal.setFalse}>Close Modal</button>
{modal.value && <div>Modal is open!</div>}
<button onClick={sidebar.toggle}>Toggle Sidebar</button>
{sidebar.value && <div>Sidebar visible</div>}
</div>
);
}💡 Solution
/**
* Custom hook quản lý trạng thái boolean với các hàm điều khiển tiện lợi
* @param {boolean} [initialValue=false] - Giá trị ban đầu
* @returns {{
* value: boolean,
* toggle: () => void,
* setTrue: () => void,
* setFalse: () => void
* }}
*/
function useToggle(initialValue = false) {
const [value, setValue] = React.useState(initialValue);
const toggle = () => setValue((prev) => !prev);
const setTrue = () => setValue(true);
const setFalse = () => setValue(false);
return { value, toggle, setTrue, setFalse };
}
// ────────────────────────────────────────────────
// Ví dụ sử dụng
// ────────────────────────────────────────────────
function ToggleDemo() {
const modal = useToggle(false);
const sidebar = useToggle(true);
return (
<div>
<button onClick={modal.toggle}>Toggle Modal</button>
<button onClick={modal.setTrue}>Open Modal</button>
<button onClick={modal.setFalse}>Close Modal</button>
{modal.value && <div>Modal is open!</div>}
<hr />
<button onClick={sidebar.toggle}>Toggle Sidebar</button>
{sidebar.value && <div>Sidebar visible</div>}
</div>
);
}
/* Kết quả ví dụ:
- Ban đầu: Modal đóng, Sidebar mở
- Nhấn "Toggle Modal" → Modal mở → nhấn lần nữa → Modal đóng
- Nhấn "Open Modal" → Modal mở (dù trước đó đang đóng)
- Nhấn "Close Modal" → Modal đóng ngay lập tức
- Sidebar có thể bật/tắt độc lập
*/⭐⭐ Level 2: Nhận Biết Pattern (25 phút)
/**
* 🎯 Mục tiêu: Create useLocalStorage hook
* ⏱️ Thời gian: 25 phút
*
* Requirements:
* - Sync state với localStorage
* - Auto-save khi state changes
* - Auto-load từ localStorage on mount
* - Handle JSON serialization
* - Handle localStorage errors (quota, private mode)
*
* Usage:
* const [name, setName] = useLocalStorage('username', 'Guest');
* // name auto loads from localStorage
* // setName auto saves to localStorage
*
* 🤔 DESIGN DECISIONS:
*
* Approach A: useState + useEffect
* - useState for state
* - useEffect to sync localStorage
* Pros: Simple, straightforward
* Cons: 2 separate hooks, potential race conditions
*
* Approach B: useReducer
* - Reducer handles both state + localStorage
* - Actions: SET_VALUE, LOAD_FROM_STORAGE
* Pros: Centralized logic, atomic updates
* Cons: More boilerplate
*
* 💭 WHICH APPROACH AND WHY?
* (Document your decision in comments)
*/
// TODO: Implement useLocalStorage
// Hints:
// - localStorage.getItem(key)
// - localStorage.setItem(key, value)
// - JSON.parse(), JSON.stringify()
// - Try-catch for errors
// - Check if window.localStorage exists (SSR)
// Test:
function LocalStorageDemo() {
const [name, setName] = useLocalStorage('username', 'Guest');
const [count, setCount] = useLocalStorage('count', 0);
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p>Stored name: {name}</p>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<p>Refresh page - values persist!</p>
</div>
);
}💡 Solution
/**
* Custom hook đồng bộ state với localStorage
* Tự động load khi mount, tự động save khi state thay đổi
* @template T
* @param {string} key - Key trong localStorage
* @param {T} initialValue - Giá trị mặc định nếu chưa có dữ liệu
* @returns {[T, (value: T | ((val: T) => T)) => void]}
*/
function useLocalStorage(key, initialValue) {
// Load từ localStorage khi mount (chỉ chạy 1 lần)
const [storedValue, setStoredValue] = React.useState(() => {
try {
const item = window.localStorage.getItem(key);
// Nếu có dữ liệu → parse, không có → dùng initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValue;
}
});
// Mỗi khi storedValue thay đổi → lưu vào localStorage
React.useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.warn(`Error writing localStorage key “${key}”:`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// ────────────────────────────────────────────────
// Ví dụ sử dụng
// ────────────────────────────────────────────────
function LocalStorageDemo() {
const [name, setName] = useLocalStorage('username', 'Guest');
const [count, setCount] = useLocalStorage('count', 0);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h2>useLocalStorage Demo</h2>
<div style={{ marginBottom: '20px' }}>
<label>
Name:{' '}
<input
value={name}
onChange={(e) => setName(e.target.value)}
style={{ padding: '8px', fontSize: '16px' }}
/>
</label>
<p>
<strong>Stored name:</strong> {name || 'Guest'}
</p>
</div>
<div>
<button
onClick={() => setCount(count + 1)}
style={{ padding: '10px 20px', fontSize: '16px' }}
>
Count: {count}
</button>
</div>
<p style={{ marginTop: '30px', color: '#555' }}>
🔄 Refresh trang hoặc đóng tab → dữ liệu vẫn còn!
</p>
</div>
);
}
/* Kết quả ví dụ:
- Lần đầu mở trang: name = "Guest", count = 0
- Thay đổi input → giá trị tự động lưu vào localStorage
- Tăng count → cũng tự động lưu
- Refresh trang → mọi giá trị được khôi phục chính xác
- Mở DevTools → Application → Local Storage → thấy 2 key: "username" và "count"
*/Design Decision: Chọn Approach A (useState + useEffect)
Lý do:
- Đơn giản, dễ hiểu, ít boilerplate
- Không cần reducer vì chỉ có 1 action chính (set value)
- useEffect chỉ chạy khi value thay đổi → performance tốt
- Xử lý lỗi localStorage (private mode, quota exceeded) bằng try/catch
- Hoạt động tốt với SSR nếu thêm kiểm tra
typeof window !== 'undefined'
⭐⭐⭐ Level 3: Kịch Bản Thực Tế (40 phút)
/**
* 🎯 Mục tiêu: Create usePagination hook
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là developer, tôi muốn pagination logic reusable"
*
* ✅ Acceptance Criteria:
* - [ ] Track current page
* - [ ] Calculate total pages from total items
* - [ ] Provide: currentPage, totalPages, goToPage, nextPage, prevPage
* - [ ] Prevent going < 1 or > totalPages
* - [ ] Calculate offset for API (skip = (page - 1) * pageSize)
* - [ ] Reset to page 1 when totalItems changes
*
* Usage:
* const pagination = usePagination({
* totalItems: 100,
* itemsPerPage: 10,
* initialPage: 1
* });
*
* <button onClick={pagination.prevPage} disabled={pagination.currentPage === 1}>
* Previous
* </button>
* <span>Page {pagination.currentPage} of {pagination.totalPages}</span>
* <button onClick={pagination.nextPage} disabled={pagination.currentPage === pagination.totalPages}>
* Next
* </button>
*
* 🎨 State Shape:
* {
* currentPage: 1,
* totalPages: 10,
* itemsPerPage: 10,
* totalItems: 100,
* offset: 0
* }
*
* 🚨 Edge Cases:
* - totalItems = 0 → totalPages = 0
* - currentPage > totalPages after totalItems reduces
* - Negative page numbers
* - Non-integer inputs
*
* 📝 Implementation Checklist:
* - [ ] useReducer with actions: SET_PAGE, NEXT_PAGE, PREV_PAGE, SET_TOTAL_ITEMS
* - [ ] Calculate totalPages = Math.ceil(totalItems / itemsPerPage)
* - [ ] Calculate offset = (currentPage - 1) * itemsPerPage
* - [ ] useEffect: reset to page 1 nếu totalItems changes
* - [ ] Validate: page >= 1 && page <= totalPages
*/
// TODO: Implement usePagination
// Test with real API:
function PaginatedUsers() {
const [totalItems, setTotalItems] = useState(0);
const pagination = usePagination({
totalItems,
itemsPerPage: 5,
initialPage: 1,
});
const { data, loading, error } = useFetch(
`https://jsonplaceholder.typicode.com/users?_start=${pagination.offset}&_limit=5`,
);
// Update total on initial load
useEffect(() => {
if (data && totalItems === 0) {
setTotalItems(10); // Total users in API
}
}, [data]);
return (
<div>
{loading && <div>Loading...</div>}
{error && <div>Error: {error}</div>}
{data && (
<>
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<div>
<button
onClick={pagination.prevPage}
disabled={pagination.currentPage === 1}
>
Previous
</button>
<span>
Page {pagination.currentPage} of {pagination.totalPages}
</span>
<button
onClick={pagination.nextPage}
disabled={pagination.currentPage === pagination.totalPages}
>
Next
</button>
</div>
</>
)}
</div>
);
}💡 Solution
/**
* Custom hook quản lý logic phân trang (pagination)
* @param {Object} options
* @param {number} options.totalItems - Tổng số item (thường từ API)
* @param {number} [options.itemsPerPage=10] - Số item mỗi trang
* @param {number} [options.initialPage=1] - Trang bắt đầu
* @returns {{
* currentPage: number,
* totalPages: number,
* itemsPerPage: number,
* offset: number,
* goToPage: (page: number) => void,
* nextPage: () => void,
* prevPage: () => void,
* canGoNext: boolean,
* canGoPrev: boolean
* }}
*/
function usePagination({
totalItems,
itemsPerPage = 10,
initialPage = 1,
} = {}) {
const [currentPage, setCurrentPage] = React.useState(initialPage);
// Tính toán lại khi totalItems thay đổi → reset về trang 1 nếu cần
React.useEffect(() => {
// Nếu totalItems giảm mạnh → currentPage có thể vượt quá totalPages
const newTotalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
if (currentPage > newTotalPages) {
setCurrentPage(newTotalPages);
}
// Nếu totalItems = 0 → về trang 1
if (totalItems === 0) {
setCurrentPage(1);
}
}, [totalItems, itemsPerPage, currentPage]);
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const offset = (currentPage - 1) * itemsPerPage;
const goToPage = (page) => {
const target = Math.max(1, Math.min(page, totalPages));
setCurrentPage(target);
};
const nextPage = () => {
if (currentPage < totalPages) {
setCurrentPage((prev) => prev + 1);
}
};
const prevPage = () => {
if (currentPage > 1) {
setCurrentPage((prev) => prev - 1);
}
};
return {
currentPage,
totalPages,
itemsPerPage,
offset,
goToPage,
nextPage,
prevPage,
canGoNext: currentPage < totalPages,
canGoPrev: currentPage > 1,
};
}
// ────────────────────────────────────────────────
// Ví dụ sử dụng với API thực tế (jsonplaceholder)
// ────────────────────────────────────────────────
function PaginatedUsers() {
const [totalItems, setTotalItems] = React.useState(0);
const pagination = usePagination({
totalItems,
itemsPerPage: 5,
initialPage: 1,
});
const { data, loading, error } = useFetch(
`https://jsonplaceholder.typicode.com/users?_start=${pagination.offset}&_limit=5`,
);
// Cập nhật totalItems một lần khi load danh sách đầu tiên
React.useEffect(() => {
if (data && totalItems === 0) {
// jsonplaceholder có 10 users
setTotalItems(10);
}
}, [data, totalItems]);
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h2>Users (Paginated)</h2>
{loading && <p>Loading users...</p>}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{data && (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{data.map((user) => (
<li
key={user.id}
style={{
padding: '12px',
borderBottom: '1px solid #eee',
}}
>
<strong>{user.name}</strong>
<br />
<small>{user.email}</small>
</li>
))}
</ul>
<div
style={{
marginTop: '24px',
display: 'flex',
alignItems: 'center',
gap: '16px',
justifyContent: 'center',
}}
>
<button
onClick={pagination.prevPage}
disabled={!pagination.canGoPrev}
style={{
padding: '8px 16px',
cursor: pagination.canGoPrev ? 'pointer' : 'not-allowed',
}}
>
Previous
</button>
<span>
Page <strong>{pagination.currentPage}</strong> of{' '}
<strong>{pagination.totalPages}</strong>
</span>
<button
onClick={pagination.nextPage}
disabled={!pagination.canGoNext}
style={{
padding: '8px 16px',
cursor: pagination.canGoNext ? 'pointer' : 'not-allowed',
}}
>
Next
</button>
</div>
</>
)}
</div>
);
}
/* Kết quả ví dụ:
- Trang 1: hiển thị users 1–5
- Nhấn Next → Trang 2: users 6–10
- Nhấn Previous → quay lại Trang 1
- Không thể Next khi đang ở trang 2 (vì total = 10, 2 trang)
- Không thể Previous khi đang ở trang 1
- Nếu totalItems thay đổi (ví dụ API trả về 7 users), tự động điều chỉnh totalPages và reset page nếu cần
*/⭐⭐⭐⭐ Level 4: Quyết Định Kiến Trúc (60 phút)
/**
* 🎯 Mục tiêu: Create useInfiniteScroll hook
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Context: Infinite scroll là common pattern (Twitter, Instagram)
* Cần reusable hook cho nhiều lists.
*
* Design Questions:
*
* 1. State Management:
* Option A: useState for each piece
* Option B: useReducer for combined state
* → DECIDE & DOCUMENT
*
* 2. Scroll Detection:
* Option A: window scroll listener
* Option B: IntersectionObserver (sentinel element)
* → DECIDE & DOCUMENT
*
* 3. Loading More:
* Option A: Hook handles fetching
* Option B: Hook only detects scroll, component fetches
* → DECIDE & DOCUMENT
*
* 📝 Design Doc:
*
* ## State Management Decision
* Choose: useReducer
* Reason: Multiple related states (items, page, hasMore, loading)
*
* ## Scroll Detection Decision
* Choose: IntersectionObserver
* Reason: Better performance, no scroll event spam
*
* ## Fetch Strategy Decision
* Choose: Hook returns loadMore callback
* Reason: Flexibility (works with any API)
*
* 💻 PHASE 2: Implementation (30 phút)
*
* Hook API:
* const { items, loadMore, loading, hasMore, observer } = useInfiniteScroll({
* fetchFunction: (page) => fetch(`/api/items?page=${page}`),
* initialPage: 1
* });
*
* Usage:
* - Append observer ref to sentinel element: <div ref={observer} />
* - When sentinel visible → auto call loadMore
*
* State:
* {
* items: [],
* page: 1,
* loading: false,
* hasMore: true
* }
*
* Actions:
* - LOAD_START
* - LOAD_SUCCESS (append items, increment page)
* - LOAD_ERROR
* - NO_MORE_DATA
*
* 🧪 PHASE 3: Testing (10 phút)
*
* Test cases:
* - Initial load → show first page
* - Scroll to bottom → load page 2
* - Continue scrolling → load pages 3, 4...
* - No more data → stop loading
* - Error handling → retry option
*/
// TODO: Implement useInfiniteScroll
// Hints:
// - useRef for IntersectionObserver
// - useEffect setup observer
// - Cleanup: observer.disconnect()
// - Check entries[0].isIntersecting
// Test:
function InfinitePhotoGallery() {
const fetchPhotos = async (page) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`,
);
const data = await res.json();
return data;
};
const { items, loading, hasMore, observerRef } = useInfiniteScroll({
fetchFunction: fetchPhotos,
initialPage: 1,
});
return (
<div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '10px',
}}
>
{items.map((photo) => (
<img
key={photo.id}
src={photo.thumbnailUrl}
alt={photo.title}
style={{ width: '100%' }}
/>
))}
</div>
{loading && <div>Loading more...</div>}
{hasMore && (
<div
ref={observerRef}
style={{ height: '20px' }}
/>
)}
{!hasMore && <div>No more photos</div>}
</div>
);
}💡 Solution
/**
* Custom hook hỗ trợ infinite scroll sử dụng IntersectionObserver
* Hook chỉ quản lý việc phát hiện scroll và trigger loadMore callback,
* component chịu trách nhiệm fetch data và append items.
*
* @param {Object} options
* @param {Function} options.loadMore - async function gọi để tải thêm dữ liệu
* nhận tham số currentPage và trả về array items mới
* @param {number} [options.initialPage=1] - trang bắt đầu
* @param {boolean} [options.enabled=true] - có bật infinite scroll hay không
* @returns {{
* items: any[],
* page: number,
* loading: boolean,
* hasMore: boolean,
* error: string | null,
* loadMore: () => Promise<void>,
* observerRef: (node: Element | null) => void,
* reset: () => void
* }}
*/
function useInfiniteScroll({
loadMore, // async (page) => Promise<any[]>
initialPage = 1,
enabled = true,
} = {}) {
const [items, setItems] = React.useState([]);
const [page, setPage] = React.useState(initialPage);
const [loading, setLoading] = React.useState(false);
const [hasMore, setHasMore] = React.useState(true);
const [error, setError] = React.useState(null);
const observer = React.useRef(null);
const sentinelRef = React.useRef(null);
// Cleanup observer khi unmount
React.useEffect(() => {
return () => {
if (observer.current) {
observer.current.disconnect();
}
};
}, []);
// Tạo IntersectionObserver
React.useEffect(() => {
if (!enabled || !hasMore || loading) return;
observer.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMoreFn();
}
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observer.current.observe(sentinelRef.current);
}
return () => {
if (observer.current && sentinelRef.current) {
observer.current.unobserve(sentinelRef.current);
}
};
}, [enabled, hasMore, loading, page]);
const loadMoreFn = React.useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
setError(null);
try {
const newItems = await loadMore(page);
if (!newItems || newItems.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...newItems]);
setPage((prev) => prev + 1);
// Nếu API trả về ít hơn mong đợi → coi như hết
if (newItems.length < 5) {
// ngưỡng tùy ý
setHasMore(false);
}
} catch (err) {
setError(err.message || 'Failed to load more items');
setHasMore(false);
} finally {
setLoading(false);
}
}, [loading, hasMore, page, loadMore]);
// Manual trigger load more (cho nút "Load more" fallback)
const manualLoadMore = React.useCallback(() => {
if (!loading && hasMore) {
loadMoreFn();
}
}, [loading, hasMore, loadMoreFn]);
// Reset toàn bộ state
const reset = React.useCallback(() => {
setItems([]);
setPage(initialPage);
setLoading(false);
setHasMore(true);
setError(null);
}, [initialPage]);
// Ref callback cho sentinel element
const observerRef = React.useCallback(
(node) => {
if (node !== null) {
sentinelRef.current = node;
if (observer.current && enabled) {
observer.current.observe(node);
}
}
},
[enabled],
);
// Load trang đầu tiên tự động nếu enabled
React.useEffect(() => {
if (enabled && page === initialPage && items.length === 0 && !loading) {
loadMoreFn();
}
}, [enabled, initialPage, loadMoreFn, items.length, loading, page]);
return {
items,
page,
loading,
hasMore,
error,
loadMore: manualLoadMore,
observerRef,
reset,
};
}
// ────────────────────────────────────────────────
// Ví dụ sử dụng
// ────────────────────────────────────────────────
function InfinitePhotoGallery() {
const fetchPhotos = async (page) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`,
);
if (!res.ok) throw new Error('Network response was not ok');
return res.json();
};
const { items, loading, hasMore, error, observerRef } = useInfiniteScroll({
loadMore: fetchPhotos,
initialPage: 1,
enabled: true,
});
return (
<div style={{ padding: '20px' }}>
<h2>Infinite Photo Gallery</h2>
{error && (
<div style={{ color: 'red', marginBottom: '16px' }}>
{error}
<button
onClick={() => {
// Có thể thêm retry logic ở đây
window.location.reload();
}}
style={{ marginLeft: '12px' }}
>
Retry
</button>
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '16px',
}}
>
{items.map((photo) => (
<div
key={photo.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<img
src={photo.thumbnailUrl}
alt={photo.title}
style={{ width: '100%', height: 'auto', display: 'block' }}
loading='lazy'
/>
<div style={{ padding: '8px', fontSize: '14px' }}>
{photo.title.substring(0, 40)}
{photo.title.length > 40 ? '...' : ''}
</div>
</div>
))}
</div>
{loading && (
<div
style={{ textAlign: 'center', padding: '40px 0', fontSize: '18px' }}
>
Loading more photos...
</div>
)}
{hasMore && !loading && (
<div
ref={observerRef}
style={{
height: '80px',
margin: '20px 0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Sentinel element – khi scroll đến đây sẽ tự động load */}
</div>
)}
{!hasMore && items.length > 0 && (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#666' }}>
No more photos to load
</div>
)}
{items.length === 0 && !loading && !error && (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
Loading initial photos...
</div>
)}
</div>
);
}
/* Kết quả ví dụ:
- Trang tự động tải 10 ảnh đầu tiên khi mount
- Khi scroll xuống gần cuối (sentinel element hiện ra) → tự động tải trang tiếp theo
- Mỗi lần load thêm 10 ảnh, append vào danh sách
- Khi API hết dữ liệu (hoặc trả về mảng rỗng) → hasMore = false, hiển thị thông báo "No more photos"
- Hỗ trợ error handling và lazy loading ảnh
- Có thể reset bằng cách gọi hook.reset() nếu cần
*/Design Decisions (tóm tắt):
State Management → useState riêng lẻ (không dùng useReducer)
→ Đơn giản hơn, ít boilerplate, đủ cho trường hợp nàyScroll Detection → IntersectionObserver (sentinel)
→ Hiệu suất tốt, không gây lag như window scroll listenerFetch Strategy → Hook chỉ trigger loadMore callback
→ Linh hoạt: component quyết định cách fetch (useFetch, axios, fetch, graphql…)
⭐⭐⭐⭐⭐ Level 5: Production Challenge (90 phút)
/**
* 🎯 Mục tiêu: Build useDataTable Hook Suite
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
*
* "Reusable Data Table System" - Hook suite cho data tables
*
* Hooks to Build:
* 1. useDataTable (main hook - composes others)
* 2. useTableSort
* 3. useTableFilter
* 4. useTableSelection
* 5. useTablePagination (from Level 3)
*
* 🏗️ Technical Design:
*
* 1. Hook Composition Pattern:
* function useDataTable(data, config) {
* const sort = useTableSort(data, config.defaultSort);
* const filter = useTableFilter(sort.data, config.filters);
* const selection = useTableSelection(filter.data);
* const pagination = useTablePagination({
* totalItems: filter.data.length,
* itemsPerPage: config.pageSize
* });
*
* return {
* data: pagination.paginatedData,
* sort,
* filter,
* selection,
* pagination
* };
* }
*
* 2. useTableSort:
* - State: { column: null, direction: 'asc' }
* - Actions: SORT_BY_COLUMN, TOGGLE_DIRECTION
* - Returns: { data, sortBy, sortDirection, toggleSort }
*
* 3. useTableFilter:
* - State: { filters: {} }
* - Actions: SET_FILTER, CLEAR_FILTER, CLEAR_ALL
* - Returns: { data, filters, setFilter, clearFilter }
*
* 4. useTableSelection:
* - State: { selected: Set(), selectAll: false }
* - Actions: SELECT, DESELECT, TOGGLE, SELECT_ALL, DESELECT_ALL
* - Returns: { selected, toggleSelection, selectAll, clearSelection }
*
* 5. Hook Composition Flow:
* Raw Data
* ↓ useTableSort
* Sorted Data
* ↓ useTableFilter
* Filtered Data
* ↓ useTableSelection (on filtered)
* Selected Items
* ↓ useTablePagination
* Paginated Data
*
* ✅ Production Checklist:
* - [ ] Each hook testable independently
* - [ ] Hooks composable (work together)
* - [ ] Type-safe (document expected shapes)
* - [ ] Performance (memo filtered/sorted data)
* - [ ] Edge cases:
* - [ ] Empty data
* - [ ] Sort by non-existent column
* - [ ] Select all with pagination
* - [ ] Filter removes selected items
* - [ ] Demo component using all hooks
* - [ ] Documentation for each hook
*
* 📝 Documentation Template (for each hook):
*
* ## useTableSort
*
* ### Purpose
* Handles table column sorting
*
* ### API
* ```js
* const { data, column, direction, toggleSort } = useTableSort(rawData, defaultSort);
* ```
*
* ### Parameters
* - rawData: Array - Data to sort
* - defaultSort: Object - { column: string, direction: 'asc'|'desc' }
*
* ### Returns
* - data: Array - Sorted data
* - column: string - Current sort column
* - direction: 'asc'|'desc' - Current direction
* - toggleSort: Function - (columnName) => void
*
* ### Example
* ```js
* const users = [{ name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }];
* const sort = useTableSort(users, { column: 'name', direction: 'asc' });
* ```
*
* 🔍 Self-Review:
* - [ ] Hooks don't violate Rules of Hooks
* - [ ] Each hook has single responsibility
* - [ ] Composition works smoothly
* - [ ] No prop drilling (hooks provide direct access)
* - [ ] Documented edge cases handled
*/
// TODO: Implement hook suite
// Starter: Sample data
const sampleUsers = [
{ id: 1, name: 'Alice', age: 30, role: 'Admin', active: true },
{ id: 2, name: 'Bob', age: 25, role: 'User', active: false },
{ id: 3, name: 'Charlie', age: 35, role: 'User', active: true },
{ id: 4, name: 'Diana', age: 28, role: 'Admin', active: true },
// ... 20 more users
];
// TODO: Implement hooks
// Demo component:
function DataTableDemo() {
const table = useDataTable(sampleUsers, {
defaultSort: { column: 'name', direction: 'asc' },
pageSize: 5,
filters: ['role', 'active'],
});
return (
<div>
{/* Filters */}
<div>
<select
onChange={(e) => table.filter.setFilter('role', e.target.value)}
>
<option value=''>All Roles</option>
<option value='Admin'>Admin</option>
<option value='User'>User</option>
</select>
<label>
<input
type='checkbox'
checked={table.filter.filters.active}
onChange={(e) => table.filter.setFilter('active', e.target.checked)}
/>
Active Only
</label>
</div>
{/* Selection */}
<div>
<button onClick={table.selection.selectAll}>Select All</button>
<button onClick={table.selection.clearSelection}>
Clear Selection
</button>
<span>Selected: {table.selection.selected.size}</span>
</div>
{/* Table */}
<table>
<thead>
<tr>
<th>
<input
type='checkbox'
checked={table.selection.allSelected}
onChange={table.selection.toggleSelectAll}
/>
</th>
<th onClick={() => table.sort.toggleSort('name')}>
Name{' '}
{table.sort.column === 'name' &&
(table.sort.direction === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => table.sort.toggleSort('age')}>
Age{' '}
{table.sort.column === 'age' &&
(table.sort.direction === 'asc' ? '↑' : '↓')}
</th>
<th>Role</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{table.data.map((user) => (
<tr key={user.id}>
<td>
<input
type='checkbox'
checked={table.selection.selected.has(user.id)}
onChange={() => table.selection.toggleSelection(user.id)}
/>
</td>
<td>{user.name}</td>
<td>{user.age}</td>
<td>{user.role}</td>
<td>{user.active ? 'Active' : 'Inactive'}</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<div>
<button
onClick={table.pagination.prevPage}
disabled={table.pagination.currentPage === 1}
>
Previous
</button>
<span>
Page {table.pagination.currentPage} of {table.pagination.totalPages}
</span>
<button
onClick={table.pagination.nextPage}
disabled={
table.pagination.currentPage === table.pagination.totalPages
}
>
Next
</button>
</div>
</div>
);
}💡 Solution
/**
* === useTableSort ===
* Hook quản lý sắp xếp bảng theo cột
*/
function useTableSort(data, defaultSort = { column: null, direction: 'asc' }) {
const [sort, setSort] = React.useState(defaultSort);
const sortedData = React.useMemo(() => {
if (!sort.column) return [...data];
return [...data].sort((a, b) => {
const aValue = a[sort.column];
const bValue = b[sort.column];
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sort.direction === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
if (aValue < bValue) return sort.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sort.direction === 'asc' ? 1 : -1;
return 0;
});
}, [data, sort.column, sort.direction]);
const toggleSort = (column) => {
setSort((prev) => ({
column,
direction:
prev.column === column && prev.direction === 'asc' ? 'desc' : 'asc',
}));
};
return {
data: sortedData,
column: sort.column,
direction: sort.direction,
toggleSort,
};
}
/**
* === useTableFilter ===
* Hook quản lý lọc dữ liệu theo nhiều trường
*/
function useTableFilter(data, filterableFields = []) {
const [filters, setFilters] = React.useState({});
const filteredData = React.useMemo(() => {
return data.filter((item) => {
return Object.entries(filters).every(([key, value]) => {
if (value === undefined || value === '' || value === null) return true;
const itemValue = item[key];
if (typeof value === 'boolean') {
return itemValue === value;
}
if (typeof itemValue === 'string') {
return itemValue.toLowerCase().includes(value.toLowerCase());
}
return itemValue === value;
});
});
}, [data, filters]);
const setFilter = (key, value) => {
setFilters((prev) => ({
...prev,
[key]: value,
}));
};
const clearFilter = (key) => {
setFilters((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
const clearAllFilters = () => setFilters({});
return {
data: filteredData,
filters,
setFilter,
clearFilter,
clearAllFilters,
};
}
/**
* === useTableSelection ===
* Hook quản lý chọn nhiều bản ghi (checkbox)
*/
function useTableSelection(data) {
const [selected, setSelected] = React.useState(new Set());
const toggleSelection = (id) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const selectAll = () => {
setSelected(new Set(data.map((item) => item.id)));
};
const deselectAll = () => {
setSelected(new Set());
};
const toggleSelectAll = () => {
if (selected.size === data.length) {
deselectAll();
} else {
selectAll();
}
};
const allSelected = data.length > 0 && selected.size === data.length;
const someSelected = selected.size > 0 && selected.size < data.length;
return {
selected,
toggleSelection,
selectAll,
deselectAll,
toggleSelectAll,
allSelected,
someSelected,
clearSelection: deselectAll,
};
}
/**
* === useTablePagination (đơn giản hóa từ Level 3) ===
*/
function useTablePagination({ data, itemsPerPage = 10, initialPage = 1 }) {
const [currentPage, setCurrentPage] = React.useState(initialPage);
const totalPages = Math.max(1, Math.ceil(data.length / itemsPerPage));
const paginatedData = React.useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return data.slice(start, start + itemsPerPage);
}, [data, currentPage, itemsPerPage]);
const goToPage = (page) => {
const target = Math.max(1, Math.min(page, totalPages));
setCurrentPage(target);
};
return {
currentPage,
totalPages,
paginatedData,
goToPage,
nextPage: () => goToPage(currentPage + 1),
prevPage: () => goToPage(currentPage - 1),
canGoNext: currentPage < totalPages,
canGoPrev: currentPage > 1,
};
}
/**
* === useDataTable - Composition Hook ===
* Kết hợp tất cả các tính năng trên thành một API thống nhất
*/
function useDataTable(rawData, config = {}) {
const { defaultSort, pageSize = 8 } = config;
const sort = useTableSort(rawData, defaultSort);
const filter = useTableFilter(sort.data);
const selection = useTableSelection(filter.data);
const pagination = useTablePagination({
data: filter.data,
itemsPerPage: pageSize,
initialPage: 1,
});
return {
// Dữ liệu cuối cùng đã được xử lý
data: pagination.paginatedData,
// Trạng thái & actions của từng tính năng
sort,
filter,
selection,
pagination,
// Tiện ích tổng hợp
totalItems: rawData.length,
filteredCount: filter.data.length,
selectedCount: selection.selected.size,
};
}
// ────────────────────────────────────────────────
// Demo component sử dụng toàn bộ suite
// ────────────────────────────────────────────────
const sampleUsers = [
{ id: 1, name: 'Alice', age: 30, role: 'Admin', active: true },
{ id: 2, name: 'Bob', age: 25, role: 'User', active: false },
{ id: 3, name: 'Charlie', age: 35, role: 'User', active: true },
{ id: 4, name: 'Diana', age: 28, role: 'Admin', active: true },
{ id: 5, name: 'Eve', age: 42, role: 'Editor', active: true },
{ id: 6, name: 'Frank', age: 31, role: 'User', active: false },
{ id: 7, name: 'Grace', age: 29, role: 'Admin', active: true },
{ id: 8, name: 'Henry', age: 37, role: 'User', active: true },
// ... có thể thêm nhiều record hơn
];
function DataTableDemo() {
const table = useDataTable(sampleUsers, {
defaultSort: { column: 'name', direction: 'asc' },
pageSize: 5,
});
return (
<div style={{ padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<h2>Data Table with Hooks</h2>
{/* Controls */}
<div
style={{
marginBottom: '24px',
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
}}
>
{/* Filter by role */}
<select
onChange={(e) =>
table.filter.setFilter('role', e.target.value || undefined)
}
style={{ padding: '8px' }}
>
<option value=''>All Roles</option>
<option value='Admin'>Admin</option>
<option value='User'>User</option>
<option value='Editor'>Editor</option>
</select>
{/* Active only */}
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type='checkbox'
checked={table.filter.filters.active === true}
onChange={(e) =>
table.filter.setFilter(
'active',
e.target.checked ? true : undefined,
)
}
/>
Active only
</label>
{/* Selection info */}
<div style={{ marginLeft: 'auto' }}>
Selected: {table.selectedCount} / {table.filteredCount} items
</div>
</div>
{/* Table */}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f4f4f5' }}>
<th style={{ padding: '12px', textAlign: 'left', width: '40px' }}>
<input
type='checkbox'
checked={table.selection.allSelected}
onChange={table.selection.toggleSelectAll}
/>
</th>
<th
onClick={() => table.sort.toggleSort('name')}
style={{ padding: '12px', cursor: 'pointer', userSelect: 'none' }}
>
Name{' '}
{table.sort.column === 'name' &&
(table.sort.direction === 'asc' ? '↑' : '↓')}
</th>
<th
onClick={() => table.sort.toggleSort('age')}
style={{ padding: '12px', cursor: 'pointer', userSelect: 'none' }}
>
Age{' '}
{table.sort.column === 'age' &&
(table.sort.direction === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '12px' }}>Role</th>
<th style={{ padding: '12px' }}>Status</th>
</tr>
</thead>
<tbody>
{table.data.map((user) => (
<tr
key={user.id}
style={{ borderBottom: '1px solid #eee' }}
>
<td style={{ padding: '12px' }}>
<input
type='checkbox'
checked={table.selection.selected.has(user.id)}
onChange={() => table.selection.toggleSelection(user.id)}
/>
</td>
<td style={{ padding: '12px' }}>{user.name}</td>
<td style={{ padding: '12px' }}>{user.age}</td>
<td style={{ padding: '12px' }}>{user.role}</td>
<td style={{ padding: '12px' }}>
<span style={{ color: user.active ? '#2e7d32' : '#d32f2f' }}>
{user.active ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<div
style={{
marginTop: '24px',
display: 'flex',
justifyContent: 'center',
gap: '16px',
alignItems: 'center',
}}
>
<button
onClick={table.pagination.prevPage}
disabled={!table.pagination.canGoPrev}
style={{ padding: '8px 16px' }}
>
Previous
</button>
<span>
Page <strong>{table.pagination.currentPage}</strong> of{' '}
<strong>{table.pagination.totalPages}</strong>
</span>
<button
onClick={table.pagination.nextPage}
disabled={!table.pagination.canGoNext}
style={{ padding: '8px 16px' }}
>
Next
</button>
</div>
</div>
);
}
/* Kết quả ví dụ:
- Sắp xếp theo tên / tuổi khi click header
- Lọc theo role và active/inactive
- Chọn từng dòng / chọn tất cả (chỉ trong trang hiện tại)
- Phân trang tự động 5 record/trang
- Khi lọc → pagination tự cập nhật lại số trang & dữ liệu
- Khi sắp xếp → filter & pagination vẫn hoạt động đúng
*/
// **Ghi chú thiết kế chính:**
// - Mỗi hook có trách nhiệm **đơn lẻ** (Single Responsibility)
// - Composition theo thứ tự logic: sort → filter → selection → pagination
// - Sử dụng **useMemo** để tránh tính toán không cần thiết
// - State được giữ riêng biệt → dễ test từng hook độc lập
// - API tổng hợp ở `useDataTable` giúp component sử dụng rất gọn gàng
// Hy vọng implementation này đủ thực tế để dùng trong dự án production nhỏ đến trung bình!📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: Custom Hook Strategies
| Strategy | Complexity | Reusability | Testability | Use When |
|---|---|---|---|---|
| Inline Logic | ✅ Simple | ❌ None | ❌ Hard | One-off, component-specific |
| Extract Function | ✅ Simple | ⚠️ Medium | ✅ Easy | Pure logic, no hooks |
| Custom Hook | ⚠️ Medium | ✅ High | ✅ Easy | Reusable stateful logic |
| Hook Composition | ❌ Complex | ✅✅ Very High | ✅ Modular | Complex features |
Decision Tree: Khi nào tạo Custom Hook?
START: Cần refactor logic?
│
├─ Logic chỉ dùng 1 component?
│ └─ YES → Keep inline ✅
│ No need custom hook yet
│
├─ Logic pure (no hooks)?
│ └─ YES → Extract regular function ✅
│ Example: validation, formatting
│
├─ Logic dùng hooks (useState, useEffect)?
│ └─ YES
│ │
│ ├─ Dùng ở 2+ components?
│ │ └─ YES → Custom Hook ✅
│ │ Example: useFetch, useForm
│ │
│ ├─ Logic phức tạp (>50 lines)?
│ │ └─ YES → Custom Hook ✅
│ │ Even if 1 component (readability)
│ │
│ └─ Will likely reuse in future?
│ └─ YES → Custom Hook ✅
│ Proactive abstraction
│
└─ Complex feature (multiple concerns)?
└─ YES → Hook Composition ✅
Example: useDataTable = sort + filter + paginationCustom Hook Best Practices
✅ DO:
// ✅ Descriptive naming
function useFetchUser(userId) { ... }
function useDebounce(value, delay) { ... }
function useLocalStorage(key, initialValue) { ... }
// ✅ Return consistent structure
function useFetch(url) {
return { data, loading, error }; // Always same shape
}
// ✅ Accept config object for flexibility
function useTable(data, config = {}) {
const {
defaultSort = { column: null, direction: 'asc' },
pageSize = 10,
} = config;
}
// ✅ Document with JSDoc
/**
* Fetches data from URL
* @param {string} url - API endpoint
* @returns {{ data, loading, error }}
*/
function useFetch(url) { ... }❌ DON'T:
// ❌ Generic naming
function useData() { ... } // Data from where?
// ❌ Inconsistent returns
function useFetch(url) {
if (loading) return [null, true, null];
return { data, loading, error }; // Different types!
}
// ❌ Too many parameters
function useFetch(url, method, headers, body, timeout, retries) { ... }
// Better: useFetch(url, options)
// ❌ Side effects in hook body
function useFetch(url) {
console.log('Fetching...'); // Don't log in hook!
// Put logging in useEffect
}Hook Composition Patterns
Pattern 1: Sequential Composition
// Each hook depends on previous
function useDataTable(data) {
const sorted = useSort(data);
const filtered = useFilter(sorted);
const paginated = usePagination(filtered);
return paginated;
}Pattern 2: Parallel Composition
// Hooks independent
function useForm(initialValues) {
const validation = useValidation(initialValues);
const storage = useLocalStorage('form', initialValues);
const submit = useSubmit();
// Combine results
return { ...validation, ...storage, ...submit };
}Pattern 3: Conditional Composition
// Use hooks conditionally (WRONG!)
function useConditional(shouldFetch) {
// ❌ VIOLATES RULES OF HOOKS
if (shouldFetch) {
const data = useFetch('/api/data');
}
}
// ✅ CORRECT
function useConditional(shouldFetch) {
const { data } = useFetch(shouldFetch ? '/api/data' : null);
// Hook always called, but fetch is conditional
}🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Shared State Misconception 🐛
// ❌ CODE/CONCEPT SAI
function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
return { count, increment };
}
function ComponentA() {
const { count, increment } = useCounter();
return <button onClick={increment}>A: {count}</button>;
}
function ComponentB() {
const { count, increment } = useCounter();
return <button onClick={increment}>B: {count}</button>;
}
function App() {
return (
<div>
<ComponentA />
<ComponentB />
</div>
);
}
// 🤔 User clicks ComponentA button
// Expected: ComponentA = 1, ComponentB = 1 (shared state?)
// Actual: ComponentA = 1, ComponentB = 0
// WHY?❓ Câu hỏi:
- Tại sao count không shared giữa A và B?
- Custom hook share gì?
- Làm sao share state thật sự?
💡 Giải thích:
- Custom hooks share LOGIC, not STATE
- Mỗi component gọi hook → separate instance
- A có state riêng, B có state riêng
- Giống như 2 components cùng dùng useState - mỗi cái có state riêng!
✅ Fix (nếu cần shared state):
// Option 1: Lift state up
function App() {
const { count, increment } = useCounter();
return (
<div>
<ComponentA
count={count}
increment={increment}
/>
<ComponentB
count={count}
increment={increment}
/>
</div>
);
}
// Option 2: Context (will learn later)
// const CountContext = createContext();Bug 2: Rules of Hooks Violation 🐛
// ❌ CODE BỊ LỖI
function useFetchOnCondition(shouldFetch, url) {
// 🐛 Conditional hook call!
if (shouldFetch) {
const { data, loading } = useFetch(url);
return { data, loading };
}
return { data: null, loading: false };
}
// React Error: "Rendered more hooks than during the previous render"❓ Câu hỏi:
- Vấn đề gì với code?
- Tại sao React throw error?
- Làm sao fix?
💡 Giải thích:
- Rules of Hooks: Hooks phải gọi trong SAME ORDER mỗi render
- Conditional → order thay đổi → React confused
- Render 1: shouldFetch=true → 1 hook called
- Render 2: shouldFetch=false → 0 hooks called
- React: "WTF? Where did hook go?"
✅ Fix:
// ✅ Always call hook, conditionally use result
function useFetchOnCondition(shouldFetch, url) {
const { data, loading } = useFetch(shouldFetch ? url : null);
return { data, loading };
}
// useFetch internally handles null URL:
function useFetch(url) {
useEffect(() => {
if (!url) return; // Skip fetch if no URL
// ... fetch logic
}, [url]);
}Bug 3: Stale Closure in Custom Hook 🐛
// ❌ CODE BỊ LỖI
function useInterval(callback, delay) {
useEffect(() => {
const interval = setInterval(callback, delay);
return () => clearInterval(interval);
}, [delay]); // 🐛 Missing callback in deps!
}
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
console.log('Count:', count); // 🐛 Always logs 0!
setCount(count + 1); // 🐛 Always sets 1!
}, 1000);
return <div>{count}</div>;
}
// Bug: count increments to 1, then stops!
// Log always shows "Count: 0"❓ Câu hỏi:
- Tại sao count luôn 0 trong callback?
- Tại sao count chỉ tăng lên 1 rồi dừng?
- Làm sao fix?
💡 Giải thích:
- Stale closure: callback captures
counttừ initial render - useEffect chỉ re-run khi delay changes
- callback không update → luôn thấy count = 0
- setCount(0 + 1) → count = 1, rồi stuck
✅ Fix:
// ✅ Option 1: Include callback in deps
function useInterval(callback, delay) {
useEffect(() => {
const interval = setInterval(callback, delay);
return () => clearInterval(interval);
}, [callback, delay]); // Add callback
}
// Problem: callback changes every render → interval resets!
// ✅ Option 2: useRef to store latest callback
function useInterval(callback, delay) {
const savedCallback = useRef();
// Update ref khi callback changes
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Setup interval
useEffect(() => {
const tick = () => {
savedCallback.current(); // Call latest callback
};
const interval = setInterval(tick, delay);
return () => clearInterval(interval);
}, [delay]); // Only re-run khi delay changes
}
// ✅ Option 3: Functional update
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount((prev) => prev + 1); // ✅ No dependency on count!
}, 1000);
}✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
Đánh dấu ✅ khi bạn tự tin:
Custom Hook Basics:
- [ ] Tôi hiểu custom hook là gì
- [ ] Tôi biết naming convention (must start with 'use')
- [ ] Tôi hiểu custom hooks share logic, not state
- [ ] Tôi biết khi nào nên extract custom hook
Hook Creation:
- [ ] Tôi biết cách extract logic thành hook
- [ ] Tôi biết cách accept parameters
- [ ] Tôi biết cách return values (object vs array)
- [ ] Tôi biết cách document hooks (JSDoc)
Hook Composition:
- [ ] Tôi biết cách compose multiple hooks
- [ ] Tôi hiểu sequential vs parallel composition
- [ ] Tôi biết cách handle dependencies giữa hooks
- [ ] Tôi tránh được conditional hook calls
Rules of Hooks:
- [ ] Tôi hiểu Rules of Hooks
- [ ] Tôi không call hooks conditionally
- [ ] Tôi không call hooks in loops
- [ ] Tôi không call hooks in regular functions
Code Review Checklist
Hook Design:
- [ ] Naming: starts with 'use', descriptive
- [ ] Single responsibility
- [ ] Configurable via parameters
- [ ] Consistent return type
Implementation:
- [ ] No Rules of Hooks violations
- [ ] Dependencies correct (no missing, no extra)
- [ ] Cleanup functions where needed
- [ ] Error handling
Reusability:
- [ ] No hardcoded values
- [ ] Flexible via config
- [ ] Works in different contexts
- [ ] Well documented
Testing:
- [ ] Can be tested independently
- [ ] Clear inputs/outputs
- [ ] Edge cases handled
- [ ] No side effects in hook body
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Bài 1: useDebounce Hook
Requirements:
- Accept value và delay
- Return debounced value
- Delay updates by specified ms
- Cancel pending update on value change
- Cancel pending update on unmount
Usage:
function SearchBox() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 500);
useEffect(() => {
if (debouncedSearch) {
// API call with debouncedSearch
}
}, [debouncedSearch]);
return (
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
);
}Hints:
- Use useState for debounced value
- Use useEffect with setTimeout
- Return cleanup function to clear timeout
💡 Solution
/**
* Custom hook debounce giá trị đầu vào
* Trì hoãn việc cập nhật giá trị cho đến khi người dùng ngừng thay đổi trong khoảng delay
* @param {any} value - Giá trị cần debounce (thường là string từ input)
* @param {number} [delay=500] - Thời gian chờ (ms) trước khi cập nhật giá trị
* @returns {any} Giá trị đã được debounce
*/
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
// Thiết lập timer để cập nhật giá trị sau delay
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cleanup: hủy timer nếu value thay đổi trước khi delay kết thúc
// hoặc khi component unmount
return () => {
clearTimeout(timer);
};
}, [value, delay]); // Chỉ chạy lại khi value hoặc delay thay đổi
return debouncedValue;
}
// ────────────────────────────────────────────────
// Ví dụ sử dụng
// ────────────────────────────────────────────────
function SearchBox() {
const [searchTerm, setSearchTerm] = React.useState('');
const debouncedSearch = useDebounce(searchTerm, 500);
// Mỗi khi debouncedSearch thay đổi → thực hiện tìm kiếm / gọi API
React.useEffect(() => {
if (debouncedSearch.trim()) {
console.log('Tìm kiếm với từ khóa:', debouncedSearch);
// Ví dụ: fetch(`/api/search?q=${debouncedSearch}`)
}
}, [debouncedSearch]);
return (
<div style={{ padding: '20px', maxWidth: '500px', margin: '0 auto' }}>
<h2>Search with Debounce</h2>
<input
type='text'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder='Nhập từ khóa để tìm kiếm...'
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
borderRadius: '6px',
border: '1px solid #ccc',
}}
/>
<div style={{ marginTop: '20px' }}>
<p>
<strong>Giá trị đang nhập:</strong> {searchTerm || '(chưa nhập)'}
</p>
<p style={{ color: debouncedSearch ? '#2e7d32' : '#757575' }}>
<strong>Giá trị debounce (sau {500}ms):</strong>{' '}
{debouncedSearch || '(đang chờ...)'}
</p>
</div>
<small style={{ color: '#666', display: 'block', marginTop: '16px' }}>
API / tìm kiếm chỉ được gọi khi bạn ngừng gõ ít nhất 500ms
</small>
</div>
);
}
/* Kết quả ví dụ:
- Gõ nhanh "react hooks" → debouncedSearch vẫn là "" hoặc giá trị cũ
- Ngừng gõ 500ms → console.log("Tìm kiếm với từ khóa: react hooks")
- Thay đổi input liên tục → không gọi API liên tục, chỉ gọi 1 lần sau khi ngừng gõ
- Xóa hết input → sau 500ms debouncedSearch trở thành ""
*/Nâng cao (60 phút)
Bài 2: useUndo Hook
Requirements:
- Track state history
- Provide: state, setState, undo, redo, canUndo, canRedo
- Limit history size (e.g., max 10 states)
- Clear future history when new state set
- Works with any state type
State shape:
{
past: [state1, state2, ...],
present: currentState,
future: [state3, state4, ...]
}Actions:
- SET (new state)
- UNDO
- REDO
- CLEAR_HISTORY
Usage:
function DrawingApp() {
const {
state: canvas,
setState: setCanvas,
undo,
redo,
canUndo,
canRedo,
reset,
} = useUndo([]);
return (
<div>
<button
onClick={undo}
disabled={!canUndo}
>
Undo
</button>
<button
onClick={redo}
disabled={!canRedo}
>
Redo
</button>
<button onClick={reset}>Clear</button>
<Canvas
data={canvas}
onChange={setCanvas}
/>
</div>
);
}💡 Solution
/**
* Custom hook hỗ trợ undo / redo cho bất kỳ giá trị state nào
* Giữ lịch sử các thay đổi (past + future) với giới hạn kích thước
* @template T
* @param {T} initialValue - Giá trị ban đầu
* @param {number} [maxHistory=10] - Số lượng trạng thái tối đa trong history (past + future)
* @returns {{
* state: T,
* setState: (newState: T | ((prev: T) => T)) => void,
* undo: () => void,
* redo: () => void,
* canUndo: boolean,
* canRedo: boolean,
* reset: () => void,
* clearHistory: () => void
* }}
*/
function useUndo(initialValue, maxHistory = 10) {
const initialState = {
past: [],
present: initialValue,
future: [],
};
const [history, setHistory] = React.useState(initialState);
const { past, present, future } = history;
const canUndo = past.length > 0;
const canRedo = future.length > 0;
const setState = React.useCallback(
(newState) => {
setHistory((prev) => {
const nextPresent =
typeof newState === 'function' ? newState(prev.present) : newState;
// Nếu giá trị không thay đổi → không thêm vào history
if (nextPresent === prev.present) {
return prev;
}
// Thêm present hiện tại vào past
// Giới hạn kích thước past
const newPast = [...prev.past, prev.present];
if (newPast.length > maxHistory) {
newPast.shift(); // xóa trạng thái cũ nhất
}
return {
past: newPast,
present: nextPresent,
future: [], // xóa future khi có thay đổi mới
};
});
},
[maxHistory],
);
const undo = React.useCallback(() => {
if (!canUndo) return;
setHistory((prev) => {
const previous = prev.past[prev.past.length - 1];
const newPast = prev.past.slice(0, -1);
return {
past: newPast,
present: previous,
future: [prev.present, ...prev.future],
};
});
}, [canUndo]);
const redo = React.useCallback(() => {
if (!canRedo) return;
setHistory((prev) => {
const next = prev.future[0];
const newFuture = prev.future.slice(1);
return {
past: [...prev.past, prev.present],
present: next,
future: newFuture,
};
});
}, [canRedo]);
const reset = React.useCallback(() => {
setHistory({
past: [],
present: initialValue,
future: [],
});
}, [initialValue]);
const clearHistory = React.useCallback(() => {
setHistory((prev) => ({
past: [],
present: prev.present,
future: [],
}));
}, []);
return {
state: present,
setState,
undo,
redo,
canUndo,
canRedo,
reset,
clearHistory,
};
}
// ────────────────────────────────────────────────
// Ví dụ sử dụng: Ứng dụng vẽ đơn giản (mảng điểm)
// ────────────────────────────────────────────────
function DrawingApp() {
const {
state: points,
setState: setPoints,
undo,
redo,
canUndo,
canRedo,
reset,
} = useUndo([], 15); // Giới hạn 15 bước history
const handleClick = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setPoints((prev) => [...prev, { x, y }]);
};
return (
<div style={{ padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<h2>useUndo Demo - Simple Drawing</h2>
<div
style={{
marginBottom: '16px',
display: 'flex',
gap: '12px',
flexWrap: 'wrap',
}}
>
<button
onClick={undo}
disabled={!canUndo}
>
Undo ({canUndo ? '✓' : '✗'})
</button>
<button
onClick={redo}
disabled={!canRedo}
>
Redo ({canRedo ? '✓' : '✗'})
</button>
<button onClick={reset}>Reset / Clear All</button>
</div>
<div
onClick={handleClick}
style={{
width: '500px',
height: '400px',
border: '2px solid #333',
background: '#f8f9fa',
position: 'relative',
cursor: 'crosshair',
overflow: 'hidden',
}}
>
{points.map((point, index) => (
<div
key={index}
style={{
position: 'absolute',
left: point.x - 6,
top: point.y - 6,
width: '12px',
height: '12px',
background: '#1976d2',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
))}
</div>
<div style={{ marginTop: '16px', color: '#555' }}>
Points: {points.length} | History: {canUndo ? past.length : 0} past,{' '}
{canRedo ? future.length : 0} future
</div>
<small style={{ color: '#777', display: 'block', marginTop: '8px' }}>
Click vào khung để vẽ điểm • Undo/Redo để quay lại hoặc tiến tới
</small>
</div>
);
}
/* Kết quả ví dụ:
- Click nhiều lần vào khung → tạo các điểm màu xanh
- Nhấn Undo → xóa điểm cuối cùng (có thể Undo nhiều bước)
- Nhấn Redo → khôi phục điểm vừa bị Undo
- Reset → xóa hết điểm và history
- Sau 15 bước → các bước cũ nhất tự động bị xóa khỏi history (giới hạn maxHistory)
- Không thể Undo khi không còn lịch sử (nút disable)
*/📚 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
- Official guide, best practices
Rules of Hooks:
- https://react.dev/warnings/invalid-hook-call-warning
- Why rules exist, common violations
Đọc thêm
useHooks Collection:
- https://usehooks.com/
- Many real-world custom hooks examples
React Hook Patterns:
- https://github.com/streamich/react-use
- Open-source hook library for inspiration
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (Đã học)
Ngày 11-14: useState
- Foundation cho custom hooks
- useFetch, useToggle dùng useState
Ngày 16-20: useEffect
- Critical cho side effects in hooks
- Cleanup patterns
Ngày 21-22: useRef
- Store mutable values
- useInterval pattern (savedCallback ref)
Ngày 26-28: useReducer
- Complex state in hooks
- useForm, usePagination patterns
Hướng tới (Sẽ học)
Ngày 30: Project 4 - Shopping Cart
- Apply custom hooks learned today
- Hook composition in practice
Ngày 31-34: Performance Hooks
- useMemo, useCallback
- Optimize custom hooks
- Memoize expensive operations
Phase 5: Context API
- Custom hooks + Context
- Global state management
- useAuth, useTheme patterns
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. When NOT to create custom hook:
// ❌ Over-abstraction
function useButtonClick(onClick) {
return { onClick }; // Unnecessary!
}
// ❌ One-off logic
function useSpecificBusinessLogic() {
// Logic chỉ dùng 1 component
// Keep inline!
}
// ✅ When to extract:
// - Used in 2+ components
// - Complex logic (>50 lines)
// - Reusable across projects2. Hook Library Organization:
src/
hooks/
useAuth.js # Domain-specific
useUser.js
useProduct.js
useAsync.js # Generic utilities
useFetch.js
useLocalStorage.js
useTable/ # Complex hooks
useTableSort.js
useTableFilter.js
index.js # Main composition3. Versioning Custom Hooks:
// v1: Simple
function useFetch(url) { ... }
// v2: Add options (backward compatible)
function useFetch(url, options = {}) { ... }
// v3: Breaking change (rename parameter)
function useFetchV3(config) { ... }
// Or: deprecate v2, migrate gradually4. Testing Strategy:
// Test custom hook với @testing-library/react-hooks
import { renderHook, act } from '@testing-library/react-hooks';
test('useFetch loads data', async () => {
const { result, waitForNextUpdate } = renderHook(() => useFetch('/api/data'));
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toBeDefined();
expect(result.current.loading).toBe(false);
});Câu Hỏi Phỏng Vấn
Junior Level:
Q: "Custom hook khác function thông thường như thế nào?"
Expected:
- Naming: must start with 'use'
- Can use other hooks inside
- Follow Rules of Hooks
- Regular function không thể dùng hooks
Q: "Viết custom hook quản lý input field"
Expected:
jsxfunction useInput(initialValue) { const [value, setValue] = useState(initialValue); const onChange = (e) => setValue(e.target.value); const reset = () => setValue(initialValue); return { value, onChange, reset }; }
Mid Level:
Q: "2 components dùng cùng custom hook có share state không? Giải thích."
Expected:
- NO, mỗi component có state riêng
- Hook chỉ share logic
- Demo với code example
- Explain khi nào cần share state (Context)
Q: "Implement useDebounce hook. Explain use case."
Expected:
- Code implementation
- Use case: search input, auto-save
- Performance benefits
- Dependencies chính xác
Senior Level:
Q: "Design custom hook library cho company. Architecture? Best practices? Testing strategy?"
Expected:
- Organization (generic vs domain-specific)
- Documentation standards
- Versioning strategy
- TypeScript support
- Testing (unit + integration)
- Performance considerations
- Code review checklist
- Migration path cho breaking changes
War Stories
Story 1: The useInterval Bug
"App có timer countdown. User reported 'timer stuck at 1'. Sau 3 giờ debug, phát hiện stale closure trong useInterval hook. Callback capture count=0 lúc mount. Fix bằng useRef pattern. Lesson: Understand closure, deps array carefully!" - Senior Engineer
Story 2: Over-Abstraction Hell
"Junior dev tạo hook cho MỌI THỨ. useButtonState, useInputValue, useModalOpen... 50+ hooks, mỗi cái 5 lines. Code review: 'This is over-engineering'. Rollback, giữ lại 10 hooks thật sự reusable. Lesson: Abstraction có cost. Extract khi có clear benefit." - Tech Lead
Story 3: Custom Hook Saved 10k Lines
"E-commerce app có 20 components fetch data. Mỗi cái 100 lines useEffect + reducer. Total 2000 lines duplicate. Extracted useFetch hook → 200 lines. Add retry logic? 1 line change instead of 20. ROI huge. Lesson: Good abstraction pays off!" - CTO
🎯 PREVIEW NGÀY MAI
Ngày 30: ⚡ Project 4 - Shopping Cart
Bạn sẽ build:
- ✨ Complete shopping cart với useReducer
- ✨ Custom hooks: useCart, useProducts, useCheckout
- ✨ Optimistic updates (add/remove items)
- ✨ localStorage persistence
- ✨ Discount codes, tax calculation
Chuẩn bị:
- Hoàn thành bài tập hôm nay
- Review useReducer patterns (Ngày 26-28)
- Review custom hooks learned today
- Nghĩ về shopping cart features cần thiết
🎉 Chúc mừng! Bạn đã hoàn thành Ngày 29!
Bạn giờ đã master:
- ✅ Custom hooks creation
- ✅ useFetch, useAsync, useForm patterns
- ✅ Hook composition
- ✅ Rules of Hooks
- ✅ Reusable logic extraction
Tomorrow: Tổng hợp tất cả vào 1 project thực tế! 💪