Custom Hook trong React: Tách Logic Dùng Lại Đúng Cách
1. Tổng quan
Khi nhiều component trong ứng dụng React cùng cần một đoạn logic giống nhau — gọi API, đọc localStorage, debounce input — bạn dễ rơi vào tình trạng copy-paste state và useEffect qua lại. Bài này chỉ cho bạn cách gói logic đó vào custom hook: một hàm bắt đầu bằng use, tận dụng các hook có sẵn (useState, useEffect, useRef…) để tái sử dụng. Đọc xong, bạn sẽ viết được useFetch, useLocalStorage, useDebounce và biết cách test chúng bằng renderHook.
2. Vấn đề: logic lặp lại giữa component
Giả sử bạn có hai component đều cần gọi API và quản lý loading/error:
function CustomerList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/customers')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...render
}
function OrderList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/orders')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...render
}
CustomerList và OrderList chứa gần như cùng một đoạn logic, chỉ khác URL. Nếu mai bạn muốn thêm retry hoặc cancel request, phải sửa ở mọi nơi — đây chính là dấu hiệu cần custom hook.
3. Custom hook là gì
Custom hook là một hàm JavaScript thông thường, tên bắt đầu bằng use, bên trong gọi các hook khác của React. Nó không trả về JSX — nó trả về state và hàm, giống như một “mini store” gắn với component gọi nó.
Lưu ý: Mỗi lần một component gọi custom hook, React tạo một instance state riêng. Hai component cùng dùng
useFetchkhông chia sẻ state với nhau — chúng chỉ chia sẻ logic.
4. Viết custom hook đầu tiên: useFetch
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
Giải thích:
cancelleddùng để tránh race condition: nếuurlđổi trước khi request cũ xong, kết quả cũ không được set vào state nữa.- Cleanup function (
return () => { cancelled = true }) chạy khi component unmount hoặcurlthay đổi — đúng vòng đời củauseEffect. - Hook trả về một object
{ data, loading, error }để nơi gọi dễ destructure.
Component giờ gọn lại:
function CustomerList() {
const { data, loading, error } = useFetch('/api/customers');
if (loading) return <p>Đang tải...</p>;
if (error) return <p>Lỗi: {error.message}</p>;
return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
function OrderList() {
const { data, loading, error } = useFetch('/api/orders');
// logic render tương tự, không còn fetch logic lặp lại
}
5. Ví dụ thứ hai: useLocalStorage
Logic đồng bộ state với localStorage cũng rất hay bị copy-paste:
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
Giải thích:
useState(() => ...)dùng lazy initializer — hàm chỉ chạy một lần khi mount, không đọclocalStoragelại ở mỗi render.- Hook trả về tuple
[value, setValue]giống yuseState, giúp người dùng quen thuộc ngay.
Dùng trong component:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Theme hiện tại: {theme}
</button>
);
}
5.1. Khi state phụ thuộc giá trị trước
Nếu bạn cần cập nhật dựa trên giá trị cũ (ví dụ counter), hãy dùng functional update để tránh stale closure:
setValue((prev) => prev + 1); // an toàn hơn setValue(value + 1)
6. Ví dụ thứ ba: useDebounce
Debounce input tìm kiếm là bài toán rất phổ biến:
// useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delayMs = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
Giải thích:
- Mỗi lần
valueđổi, mộtsetTimeoutmới được tạo;setTimeoutcũ bịclearTimeouttrong cleanup. - Chỉ khi người dùng ngừng gõ trong
delayMsthìdebouncedmới cập nhật.
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
7. Quy tắc đặt tên và Rules of Hooks
Custom hook phải tuân theo Rules of Hooks như hook built-in:
- Tên bắt đầu bằng
use— ESLint plugineslint-plugin-react-hooksdựa vào tiền tố này để kiểm tra rule, nếu không cóusenó sẽ không nhận diện đây là hook. - Chỉ gọi hook ở top-level của hàm, không gọi trong
if,for, hay callback. - Chỉ gọi hook trong function component hoặc custom hook khác, không gọi trong hàm thường.
npm install eslint-plugin-react-hooks --save-dev
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
Bật exhaustive-deps giúp bạn không quên dependency trong useEffect của custom hook — nguyên nhân phổ biến nhất gây bug “stale data”.
8. Test custom hook
Vì custom hook không trả về JSX, bạn không thể render nó như component. Thư viện @testing-library/react cung cấp renderHook để gọi hook trong môi trường test.
<!-- Không áp dụng cho dự án React; dependency cài qua npm, xem bên dưới -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('chỉ cập nhật giá trị sau khi hết delay', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
);
expect(result.current).toBe('a');
rerender({ value: 'ab', delay: 300 });
expect(result.current).toBe('a'); // chưa đổi ngay
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('ab'); // đã debounce xong
vi.useRealTimers();
});
});
Giải thích:
renderHooknhận một hàm gọi hook, trả vềresult.current— giá trị hook trả về ở lần render hiện tại.rerendermô phỏng component re-render với props mới (giốngvalueđổi khi người dùng gõ).vi.useFakeTimers()+act(() => vi.advanceTimersByTime(...))cho phép kiểm soát thời gian mà không cần chờ thật, test chạy nhanh và ổn định.
✓ useDebounce > chỉ cập nhật giá trị sau khi hết delay
9. Lỗi hay gặp và khi nào không nên dùng
Lỗi hay gặp:
- Quên cleanup trong
useEffect: vớiuseFetch, nếu không có cờcancelled, request cũ trả về sau có thể ghi đè data mới — gây hiển thị sai dữ liệu. - Thiếu dependency: nếu
useEffecttrong hook dùng biến bên ngoài nhưng không khai báo trong dependency array, hook sẽ dùng giá trị “cũ” (stale closure). - Trả về object mới mỗi render không cần thiết: nếu hook trả về
{ data, loading, error }bằng object literal mỗi lần, component dùngReact.memophía dưới có thể re-render dư thừa vì reference đổi. Có thể dùnguseMemonếu đo được vấn đề thật.
Khi nào KHÔNG nên viết custom hook:
- Logic chỉ dùng ở một chỗ duy nhất và không có khả năng tái sử dụng — tách ra chỉ làm code khó theo dõi hơn (phải nhảy qua file khác để hiểu).
- Logic hoàn toàn là tính toán thuần (pure function), không liên quan state/effect — nên viết hàm JS thông thường, không cần hook.
- Khi bạn cần chia sẻ state (không chỉ logic) giữa nhiều component ở các vị trí khác nhau trong tree — custom hook không làm được điều này, bạn cần Context hoặc state management library (Zustand, Redux…).
Lưu ý: Custom hook giải quyết vấn đề trùng lặp logic, không giải quyết vấn đề chia sẻ state giữa component. Đừng nhầm hai bài toán này.
10. Kết luận
Custom hook là công cụ tách logic stateful ra khỏi component, giúp code gọn, dễ test, dễ tái sử dụng. Bạn chỉ cần nhớ: đặt tên bắt đầu bằng use, tuân thủ Rules of Hooks, và test bằng renderHook thay vì render. Với useFetch, useLocalStorage, useDebounce ở trên, bạn đã có bộ khung để viết thêm các hook khác như useMediaQuery, useForm theo đúng tinh thần “viết một lần, dùng nhiều nơi”.