Truy cập react.dev — Hooks Reference. Đọc lần lượt từng hook: useState, useEffect, useContext, useRef, useMemo, useCallback, useReducer. Mỗi hook đọc cả phần "Pitfalls" và "Troubleshooting".
Đọc "A Complete Guide to useEffect" của Dan Abramov trên overreacted.io. Đây là bài viết dài nhưng cực kỳ quan trọng — giải thích mental model đúng về useEffect, closures, và synchronization.
Đọc bài "useEffect vs useLayoutEffect" của Kent C. Dodds. Nắm rõ timing: useLayoutEffect chạy synchronous sau DOM mutation, trước khi browser paint.
Tự implement: useDebounce, useToggle, usePrevious, useFetch, useKeyboardVisible hoặc useAppState. Không copy — tự suy nghĩ logic trước, rồi so sánh với implementation trong bài học.
Truy cập usehooks-ts.com, đọc source code của các hooks. Chú ý cách xử lý edge cases, TypeScript typing, và cleanup logic.
use, không giấu side effect khó đoán.Quy tắc 1: Chỉ gọi hooks ở top level — không gọi trong conditions, loops, hay nested functions.
Quy tắc 2: Chỉ gọi hooks trong React function components hoặc custom hooks.
React lưu trữ state của các hooks dưới dạng linked list gắn với mỗi fiber node. Mỗi lần render, React duyệt qua list này theo thứ tự. Hook thứ nhất được gọi → lấy node thứ nhất trong list; hook thứ hai → node thứ hai; v.v.
Nếu bạn đặt hook trong if, lần render đầu tiên có thể gọi 3 hooks (tạo 3 nodes), nhưng lần render sau chỉ gọi 2 hooks (vì condition thay đổi). React sẽ map sai hook với sai state node → bug nghiêm trọng.
Ví dụ SAI — hook trong condition:
function BadComponent({ showName }) {
const [age, setAge] = useState(0);
if (showName) {
const [name, setName] = useState("React");
}
const [color, setColor] = useState("blue");
return null;
}
Khi showName là true: React thấy 3 hooks → tạo list [age, name, color]. Khi showName là false: React chỉ thấy 2 hooks → map: hook 1 = age (đúng), hook 2 = color nhưng React nghĩ nó là name (SAI). Kết quả: state bị lẫn lộn hoàn toàn.
Cách sửa đúng:
function GoodComponent({ showName }) {
const [age, setAge] = useState(0);
const [name, setName] = useState("React");
const [color, setColor] = useState("blue");
return null;
}
Luôn gọi tất cả hooks, dùng condition bên trong logic xử lý, không phải bao quanh hook call.
Từ React 18, tất cả state updates đều được batch (gom nhóm) — kể cả trong setTimeout, Promise, hay event handler. React sẽ đợi tất cả setState trong cùng một "execution context" hoàn tất, rồi mới re-render MỘT LẦN.
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
}
return (
<button onClick={handleClick}>
{count} - {String(flag)}
</button>
);
}
Dù gọi 2 setState, React chỉ re-render 1 lần. Trước React 18, batching chỉ hoạt động trong event handlers.
Khi state mới phụ thuộc vào state cũ, luôn dùng functional update để tránh stale closure.
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
Ba dòng trên chỉ tăng count lên 1 (không phải 3) vì cả 3 đều đọc cùng giá trị count từ closure hiện tại.
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
Functional update nhận prev là giá trị mới nhất trong queue → kết quả đúng: count tăng 3.
Nếu initial state cần tính toán phức tạp (ví dụ: parse JSON, đọc localStorage), truyền một function thay vì giá trị. Function này chỉ được gọi ở lần render đầu tiên.
const [data, setData] = useState(() => {
const saved = localStorage.getItem("form-data");
return saved ? JSON.parse(saved) : { name: "", email: "" };
});
Nếu viết useState(JSON.parse(localStorage.getItem("form-data"))) thì mỗi lần render đều gọi parse — lãng phí performance. Lazy initializer giải quyết vấn đề này.
Closure "đóng gói" giá trị tại thời điểm nó được tạo. Nếu bạn dùng giá trị state cũ trong một callback được tạo từ render trước, bạn sẽ đọc được giá trị cũ (stale).
function StaleClosureDemo() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert("Count: " + count);
}, 3000);
}
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={handleAlertClick}>Alert</button>
</div>
);
}
Nếu click "+1" ba lần rồi click "Alert", sau 3 giây alert sẽ hiện "Count: 3" (giá trị tại thời điểm click Alert). Nếu click "Alert" trước, rồi click "+1" ba lần, alert sẽ hiện "Count: 0" — vì setTimeout capture giá trị count tại thời điểm callback được tạo.
React dùng Object.is() để so sánh từng phần tử trong dependency array giữa render trước và render sau. Nếu tất cả đều bằng nhau, effect KHÔNG chạy lại.
Object.is(3, 3);
Object.is("abc", "abc");
Object.is(NaN, NaN);
Object.is({}, {});
Ba dòng đầu trả về true. Dòng cuối trả về false vì hai object khác reference. Đây là lý do phổ biến khiến useEffect chạy vô hạn khi bạn truyền object/array vào deps.
function BadExample({ userId }) {
const options = { userId, type: "active" };
useEffect(() => {
fetchUser(options);
}, [options]);
}
Mỗi render tạo object options mới → reference mới → Object.is trả về false → effect chạy mỗi render. Fix: di chuyển object vào trong effect, hoặc dùng primitive deps.
function GoodExample({ userId }) {
useEffect(() => {
const options = { userId, type: "active" };
fetchUser(options);
}, [userId]);
}
1. Component render → tạo virtual DOM → commit DOM changes → browser paint → chạy effect.
2. Khi deps thay đổi: React chạy cleanup của effect cũ trước, rồi mới chạy effect mới.
3. Khi component unmount: React chạy cleanup của effect cuối cùng.
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(id);
};
}, []);
Cleanup function (return) được gọi khi component unmount HOẶC trước khi effect chạy lại. Nếu không cleanup interval, mỗi lần effect chạy lại sẽ tạo thêm interval mới mà không xoá cái cũ → memory leak.
Khi user thay đổi input nhanh, nhiều request được gửi đi. Response của request cũ có thể về SAU response mới → hiển thị data sai. Có 2 cách fix: boolean flag hoặc AbortController.
Cách 1: Boolean flag (đơn giản nhưng không cancel request thật sự)
useEffect(() => {
let cancelled = false;
async function fetchData() {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
if (!cancelled) {
setResults(data);
}
}
fetchData();
return () => {
cancelled = true;
};
}, [query]);
Cách 2: AbortController (tốt hơn — thật sự cancel network request)
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error);
}
}
}
fetchData();
return () => {
controller.abort();
};
}, [query]);
AbortController thực sự huỷ HTTP request ở tầng network, tiết kiệm bandwidth. Luôn check error.name !== "AbortError" để không xử lý abort như error thật.
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}
Bug: count luôn là 0 trong closure (deps là [] nên effect không bao giờ chạy lại). Kết quả: count luôn hiện 1.
Fix 1: Functional update
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Không cần đọc count từ closure — functional update luôn nhận giá trị mới nhất.
Fix 2: Thêm dependency
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
Mỗi lần count thay đổi, interval cũ bị clear và interval mới được tạo. Hoạt động đúng nhưng không tối ưu (tạo/xoá interval liên tục).
Fix 3: useRef để lưu latest value
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}
Ref luôn giữ giá trị mới nhất vì nó mutable và không bị closure "đóng băng".
useCallback(fn, deps) trả về phiên bản memoized của fn. Function chỉ được tạo lại khi deps thay đổi.
React.memo, callback mới mỗi render sẽ phá vỡ memoization → child re-render vô ích.const MemoChild = React.memo(function Child({ onClick }) {
console.log("Child rendered");
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("clicked");
}, []);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<MemoChild onClick={handleClick} />
</div>
);
}
Không có useCallback, mỗi lần Parent re-render tạo handleClick mới → React.memo thấy prop khác → Child re-render. Có useCallback, handleClick giữ reference cũ → Child skip re-render.
<button onClick={fn}> — native element không có memoization.useMemo(() => compute(a, b), [a, b]) cache kết quả tính toán. Chỉ tính lại khi deps thay đổi.
function FilteredList({ items, filter }) {
const filtered = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);
return (
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
Nếu items có 10.000 phần tử, filter mỗi render rất tốn. useMemo đảm bảo chỉ filter lại khi items hoặc filter thay đổi.
function Chart({ data, config }) {
const chartOptions = useMemo(() => ({
labels: data.map(d => d.label),
datasets: [{ values: data.map(d => d.value), color: config.color }],
}), [data, config]);
useEffect(() => {
renderChart(chartOptions);
}, [chartOptions]);
}
Không có useMemo, chartOptions mới mỗi render → useEffect chạy lại liên tục → chart re-render vô hạn.
useRef tạo một object { current: initialValue } tồn tại xuyên suốt lifecycle. Thay đổi .current KHÔNG gây re-render. Đây là điểm khác biệt cốt lõi với useState.
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus</button>
</div>
);
}
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef<number | null>(null);
function start() {
if (intervalRef.current !== null) return;
intervalRef.current = window.setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
}
function stop() {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
useEffect(() => {
return () => stop();
}, []);
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
Dùng ref để lưu interval ID vì: (1) không cần re-render khi ID thay đổi, (2) cần persist giá trị giữa các render.
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<p>
Now: {count}, Before: {prevCount}
</p>
);
}
Trick: useEffect chạy SAU render. Trong render hiện tại, ref.current vẫn là giá trị cũ. Sau render, effect cập nhật ref thành giá trị mới → sẵn sàng cho render tiếp theo.
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
Pattern này dùng khi bạn cần đọc giá trị mới nhất trong callback/timeout mà không muốn thêm dependency. Gán trực tiếp (không qua useEffect) để đảm bảo ref luôn cập nhật trước khi bất kỳ effect nào chạy.
useReducer là phiên bản nâng cao của useState. Thay vì update state trực tiếp, bạn dispatch một action, reducer function quyết định state mới dựa trên action đó.
interface State {
count: number;
step: number;
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "setStep"; payload: number }
| { type: "reset" };
const initialState: State = { count: 0, step: 1 };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + state.step };
case "decrement":
return { ...state, count: state.count - state.step };
case "setStep":
return { ...state, step: action.payload };
case "reset":
return initialState;
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count} (step: {state.step})</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: "setStep", payload: Number(e.target.value) })}
/>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
| Tiêu chí | useState | useReducer |
|---|---|---|
| State đơn giản (1-2 field) | ✅ Phù hợp | Overkill |
| State phức tạp (nhiều field liên quan) | Khó quản lý | ✅ Phù hợp |
| State transitions có logic phức tạp | Logic rải rác | ✅ Tập trung trong reducer |
| Test state logic | Khó test riêng | ✅ Reducer là pure function, dễ test |
| Performance (truyền dispatch xuống child) | Callback thay đổi mỗi render | ✅ dispatch stable, không bao giờ thay đổi |
useContext(MyContext) đọc giá trị context gần nhất. Khi Provider value thay đổi, TẤT CẢ consumers sẽ re-render — bất kể chúng có dùng phần thay đổi hay không.
const AppContext = React.createContext<{
theme: string;
locale: string;
user: { name: string };
}>({
theme: "light",
locale: "vi",
user: { name: "" },
});
function App() {
const [theme, setTheme] = useState("light");
const [locale, setLocale] = useState("vi");
const [user, setUser] = useState({ name: "Linh" });
const value = { theme, locale, user };
return (
<AppContext.Provider value={value}>
<Header />
<Content />
</AppContext.Provider>
);
}
Vấn đề: Khi chỉ theme thay đổi, value object mới được tạo → TẤT CẢ component dùng useContext(AppContext) re-render, kể cả những component chỉ dùng locale hay user.
const ThemeContext = React.createContext("light");
const LocaleContext = React.createContext("vi");
const UserContext = React.createContext({ name: "" });
function App() {
const [theme, setTheme] = useState("light");
const [locale, setLocale] = useState("vi");
const [user, setUser] = useState({ name: "Linh" });
return (
<ThemeContext.Provider value={theme}>
<LocaleContext.Provider value={locale}>
<UserContext.Provider value={user}>
<Header />
<Content />
</UserContext.Provider>
</LocaleContext.Provider>
</ThemeContext.Provider>
);
}
Giờ khi theme thay đổi, chỉ component dùng useContext(ThemeContext) re-render. Component dùng UserContext không bị ảnh hưởng.
Khi dùng useReducer + Context, tách state và dispatch thành 2 contexts riêng. dispatch không bao giờ thay đổi reference → component chỉ cần dispatch sẽ không re-render khi state thay đổi.
useEffect: Render → DOM update → Browser paint → Effect chạy (asynchronous)
useLayoutEffect: Render → DOM update → Effect chạy → Browser paint (synchronous, blocking)
function Tooltip({ targetRef, content }) {
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
if (!targetRef.current || !tooltipRef.current) return;
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
setPosition({
top: targetRect.top - tooltipRect.height - 8,
left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
});
}, [targetRef, content]);
return (
<div
ref={tooltipRef}
style={{ position: "fixed", top: position.top, left: position.left }}
>
{content}
</div>
);
}
Dùng useLayoutEffect ở đây vì cần đo DOM rồi set position TRƯỚC khi user thấy tooltip. Nếu dùng useEffect, tooltip sẽ flash ở vị trí (0,0) trước khi nhảy đến đúng vị trí → nhấp nháy (flicker).
useLayoutEffect chạy synchronous → block paint. Nếu logic nặng, sẽ khiến UI đứng. Chỉ dùng khi cần đọc/thay đổi DOM trước paint. Mọi trường hợp khác dùng useEffect.
forwardRef cho phép parent truyền ref vào child component. useImperativeHandle cho phép child tuỳ chỉnh ref value — expose API giới hạn thay vì expose toàn bộ DOM node.
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seekTo: (time: number) => void;
}
const VideoPlayer = React.forwardRef<VideoPlayerHandle, { src: string }>(
function VideoPlayer({ src }, ref) {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play() {
videoRef.current?.play();
},
pause() {
videoRef.current?.pause();
},
seekTo(time: number) {
if (videoRef.current) {
videoRef.current.currentTime = time;
}
},
}), []);
return <video ref={videoRef} src={src} />;
}
);
function App() {
const playerRef = useRef<VideoPlayerHandle>(null);
return (
<div>
<VideoPlayer ref={playerRef} src="/video.mp4" />
<button onClick={() => playerRef.current?.play()}>Play</button>
<button onClick={() => playerRef.current?.pause()}>Pause</button>
<button onClick={() => playerRef.current?.seekTo(30)}>Go to 0:30</button>
</div>
);
}
Lợi ích: Parent chỉ có thể gọi play, pause, seekTo — không thể truy cập trực tiếp DOM element. Encapsulation tốt hơn.
Custom hook là function bắt đầu bằng "use", chứa logic dùng các hooks khác. Mục đích: tái sử dụng stateful logic giữa các components.
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
Cách dùng: truyền giá trị thay đổi liên tục (search input), nhận lại giá trị chỉ cập nhật sau khi user ngừng gõ delay ms.
function SearchPage() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
function useToggle(initialValue: boolean = false): [boolean, () => void] {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(v => !v);
}, []);
return [value, toggle];
}
Cách dùng:
function Modal() {
const [isOpen, toggleOpen] = useToggle(false);
return (
<div>
<button onClick={toggleOpen}>Toggle Modal</button>
{isOpen && <div className="modal">Content</div>}
</div>
);
}
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
Trả về giá trị của value từ render trước. Hữu ích khi cần so sánh old vs new (ví dụ: animate khi số tăng/giảm).
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
setState({ data, loading: false, error: null });
})
.catch(error => {
if (error.name !== "AbortError") {
setState({ data: null, loading: false, error });
}
});
return () => {
controller.abort();
};
}, [url]);
return state;
}
Cách dùng:
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <p>{data?.name}</p>;
}
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const nextValue = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(nextValue));
return nextValue;
});
},
[key]
);
return [storedValue, setValue];
}
Cách dùng:
function Settings() {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
Current: {theme}
</button>
);
}
State tự động sync với localStorage. Refresh trang vẫn giữ giá trị.
React lưu state của hooks dưới dạng linked list theo thứ tự gọi. Mỗi render, React duyệt list theo đúng thứ tự: hook call thứ nhất → node thứ nhất, hook call thứ hai → node thứ hai...
Nếu đặt hook trong if, số lượng hook calls có thể thay đổi giữa các render. React sẽ map sai hook với sai state node → bug nghiêm trọng (state bị lẫn, crash).
Hai rules: (1) Chỉ gọi hooks ở top level. (2) Chỉ gọi trong React function component hoặc custom hook. ESLint plugin eslint-plugin-react-hooks sẽ enforce rules này tại build time.
Functional update (setState(prev => prev + 1)) dùng khi state mới phụ thuộc vào state cũ. Nó đảm bảo luôn đọc giá trị mới nhất từ React internal queue, không phụ thuộc closure.
Stale closure xảy ra khi callback (setTimeout, event handler, effect) "đóng gói" giá trị state tại thời điểm tạo. Nếu state thay đổi sau đó, callback vẫn thấy giá trị cũ.
Ví dụ: setCount(count + 1) gọi 3 lần → count chỉ tăng 1 (cả 3 đều đọc cùng giá trị count). setCount(prev => prev + 1) gọi 3 lần → count tăng 3 (mỗi lần nhận prev mới nhất).
React dùng Object.is() so sánh từng phần tử trong deps giữa render trước và render sau. Nếu tất cả bằng nhau → effect skip. Nếu bất kỳ phần tử nào khác → effect chạy lại.
Object.is({}, {}) trả về false vì so sánh bằng reference. Mỗi render tạo object mới → reference mới → deps luôn khác → effect chạy mỗi render → nếu effect gây re-render → vòng lặp vô hạn.
Fix: dùng primitive values trong deps, hoặc useMemo để stable reference, hoặc di chuyển object creation vào trong effect.
Cleanup function là function được return từ effect callback. Nó được gọi trong 2 trường hợp:
1. Trước khi effect chạy lại (khi deps thay đổi): cleanup effect cũ trước, rồi chạy effect mới.
2. Khi component unmount: cleanup effect cuối cùng.
Dùng cho: clearInterval, clearTimeout, removeEventListener, abort fetch request, unsubscribe websocket, cancel animation frame.
Không cleanup sẽ gây memory leak — event listeners, intervals, subscriptions tiếp tục chạy dù component đã bị gỡ khỏi DOM.
Cách 1 — Boolean flag: Tạo biến let cancelled = false trong effect. Cleanup set cancelled = true. Trước khi setState, check if (!cancelled). Nhược điểm: request vẫn chạy ở background.
Cách 2 — AbortController: Tạo new AbortController(), truyền signal vào fetch options. Cleanup gọi controller.abort(). Catch block kiểm tra error.name !== "AbortError" để ignore abort errors. Ưu điểm: thực sự cancel request ở tầng network.
Cách 2 luôn được ưu tiên trong production vì tiết kiệm bandwidth và tài nguyên server.
Nên dùng khi: (1) Truyền callback xuống child được wrap bằng React.memo — nếu không memoize, callback mới mỗi render sẽ break memoization. (2) Callback là dependency của useEffect/useMemo.
Không nên dùng khi: (1) Child không dùng React.memo. (2) Truyền vào native element. (3) Không đo được performance issue thực tế.
useCallback có cost: lưu function + deps vào memory, so sánh deps mỗi render. Premature optimization với useCallback có thể khiến code phức tạp hơn mà không cải thiện performance đáng kể.
useEffect: Chạy asynchronous SAU khi browser đã paint. Không block visual update.
useLayoutEffect: Chạy synchronous SAU DOM mutation nhưng TRƯỚC khi browser paint. Block paint cho đến khi hoàn tất.
Dùng useLayoutEffect khi: cần đo DOM (getBoundingClientRect), cần thay đổi DOM trước khi user thấy (tránh flicker), animate position/size.
Dùng useEffect cho mọi trường hợp khác: fetch data, subscriptions, timers, logging.
Lưu ý: useLayoutEffect chạy synchronous nên logic nặng sẽ block UI, gây jank.
useState: Thay đổi value → trigger re-render. Value được "đóng gói" trong closure của mỗi render.
useRef: Thay đổi .current → KHÔNG trigger re-render. Object ref persist giữa các render, .current luôn là giá trị mới nhất (mutable).
Dùng ref khi: (1) Lưu DOM reference. (2) Lưu interval/timeout ID. (3) Lưu giá trị cần đọc trong callback mà không muốn thêm dependency (latest value pattern). (4) Lưu previous value. (5) Đếm render count (debug). (6) Bất kỳ giá trị mutable nào không ảnh hưởng UI.
Khi Provider value thay đổi (reference khác theo Object.is), React re-render TẤT CẢ component gọi useContext cho context đó — bất kể component có dùng phần data thay đổi hay không. React không tự "select" một phần context.
Cách optimize:
1. Split contexts: Tách một context lớn thành nhiều contexts nhỏ (ThemeContext, UserContext, LocaleContext). Component chỉ subscribe context cần.
2. Tách state/dispatch: Khi dùng useReducer, tạo StateContext và DispatchContext riêng. dispatch stable → component chỉ dispatch không re-render khi state đổi.
3. useMemo Provider value: Wrap object value trong useMemo để tránh tạo reference mới mỗi render (nếu không split được).
4. React.memo + separate component: Tách phần dùng context ra child component riêng, memo ở level giữa.
useState phù hợp khi: state đơn giản (boolean, string, number), transitions đơn giản (set trực tiếp), state không liên quan đến nhau.
useReducer phù hợp khi: state phức tạp (object với nhiều field liên quan), nhiều kiểu update khác nhau, state transition có logic business (ví dụ: form validation, wizard steps), cần test state logic riêng (reducer là pure function).
Bonus: dispatch từ useReducer có stable identity (không thay đổi reference giữa các render) → an toàn truyền xuống child qua context mà không cần useCallback.
Custom hook là function bắt đầu bằng "use", bên trong dùng các React hooks khác. Mục đích: tái sử dụng stateful logic, không phải share state.
KHÔNG share state: Mỗi component gọi custom hook sẽ có instance state riêng. useToggle() trong Component A và Component B là hai state hoàn toàn độc lập.
Custom hook share logic (cách khởi tạo, cập nhật, cleanup state) — tương tự HOC hoặc render props nhưng elegant hơn, không tạo wrapper hell.
useImperativeHandle dùng cùng forwardRef để tuỳ chỉnh ref value mà parent nhận được. Thay vì expose toàn bộ DOM node, child chỉ expose một API giới hạn.
Khi nào dùng: (1) Tạo component thư viện (video player, editor, drawer) cần expose imperative API (play, pause, open, close). (2) Muốn encapsulate implementation — parent không cần biết internal DOM structure. (3) Component phức tạp cần expose nhiều methods.
Lưu ý: Đây là escape hatch. Ưu tiên declarative approach (props) trước. Chỉ dùng imperative khi declarative không phù hợp (ví dụ: focus input, scroll to position, play video).
Viết custom hook useDebounce(value, delay) trả về debounced value. Tạo SearchInput component demo: user gõ liên tục, API chỉ được gọi sau khi user ngừng gõ 500ms.
Acceptance criteria:
Viết custom hook useThrottle(value, interval). Khác debounce: throttle đảm bảo value cập nhật TỐI ĐA 1 lần mỗi interval.
Acceptance criteria:
Viết usePrevious(value) trả về giá trị render trước. Dùng để hiển thị "Count changed from X to Y".
Acceptance criteria:
undefinedViết useInterval(callback, delay). Khi delay là null thì pause interval. Callback luôn dùng giá trị mới nhất (không stale closure).
Acceptance criteria:
Viết useEventListener(eventName, handler, element?). Tự động add/remove event listener, xử lý cleanup, handler luôn latest (không stale).
Acceptance criteria:
Viết hook trả về { isVisible, height } dựa trên Keyboard.addListener.
Khi app từ background quay lại active, gọi callback refresh data nhưng throttle tối thiểu 30 giây/lần.
AppState và useRef để lưu timestamp cuốiĐóng gói pattern fetch có AbortController, expose run, cancel, loading, error.
useCallback mọi nơi làm code khó đọc nhưng không giảm render thật.Case: Screen search gọi API theo keyword. User gõ nhanh, request cũ trả về sau request mới và ghi đè kết quả mới hơn.
Cách xử lý: Debounce keyword, cancel request cũ bằng AbortController, hoặc gắn requestId trong ref và chỉ accept response mới nhất. Test bằng cách giả lập network chậm/nhanh xen kẽ.
Hook không làm code tự nhiên đúng hơn; hook chỉ gom logic. Muốn dùng được trong app thật, luôn kiểm tra 3 thứ: dependency có ổn không, cleanup có đủ không, và callback có bị stale không.
userId, bạn cleanup gì khi userId đổi?useRef thay vì useState?