React Hooks Deep Dive: Những thứ bạn nghĩ mình đã biết
Từ useState đến useTransition đào sâu vào từng hook, tránh các anti-pattern kinh điển và hiểu khi nào nên dùng cái gì.
Nguyễn Nhật Long
@nguyennhatlong1303
Mình đã từng nghĩ mình hiểu hooks sau khi đọc docs React xong. Rồi production bug xuất hiện stale closure trong useEffect, infinite re-render vì object dependency, context khiến cả cây component re-render không cần thiết. Thực ra hooks không khó, nhưng có khá nhiều chỗ nếu không để ý thì sẽ bị dính.
Bài này mình sẽ đi qua từng hook một cách thực tế không phải kiểu liệt kê API docs, mà là những gì mình thực sự gặp trong dự án.
useState cái bẫy nằm ở functional update
Mọi người đều biết useState, nhưng không phải ai cũng dùng đúng trong mọi trường hợp.
Với primitive value thì đơn giản:
1const [count, setCount] = useState(0)
Nhưng khi state là object, một lỗi rất hay gặp là mutate trực tiếp:
1// ❌ Sai React không detect được thay đổi2state.name = 'new'3setState(state)45// ✅ Đúng spread ra object mới6setState(prev => ({ ...prev, name: 'new' }))
Và đây là chỗ mình thấy nhiều bạn bỏ qua nhất functional update. Khi bạn có nhiều state update xảy ra gần nhau, hoặc update bên trong async callback, dùng setCount(count + 1) có thể cho kết quả sai vì count lúc đó là stale value. Dùng setCount(prev => prev + 1) thì React đảm bảo prev luôn là giá trị mới nhất.
Một trick nữa là lazy initialization nếu initial state cần tính toán nặng, đừng gọi hàm trực tiếp:
1// ❌ expensiveComputation() chạy mỗi lần render2const [data, setData] = useState(expensiveComputation())34// ✅ Chỉ chạy lần đầu5const [data, setData] = useState(() => expensiveComputation())
Khác biệt nhỏ nhưng ảnh hưởng performance khá lớn nếu hàm đó tốn thời gian.
useEffect và dependency array nguồn gốc của 80% bug React
Nếu có một hook mà mình thấy bị dùng sai nhiều nhất, đó là useEffect. Không phải vì nó phức tạp, mà vì mental model của nó khác với cách chúng ta nghĩ về lifecycle.
1// Mount only2useEffect(() => {3 fetchData()4}, [])56// Watch deps7useEffect(() => {8 document.title = `Count: ${count}`9}, [count])1011// Cleanup12useEffect(() => {13 const id = setInterval(tick, 1000)14 return () => clearInterval(id) // ← quan trọng15}, [])
Hai lỗi phổ biến nhất:
1. Infinite loop vì object/array trong deps
1// ❌ options là object mới mỗi render → loop vô tận2useEffect(() => {3 fetchData(options)4}, [options])56// ✅ Dùng useMemo hoặc move object ra ngoài component7const options = useMemo(() => ({ page, limit }), [page, limit])
2. Dùng useEffect như event handler
1// ❌ Anti-pattern không cần useEffect cho việc này2const [submitted, setSubmitted] = useState(false)3useEffect(() => {4 if (submitted) sendForm(data)5}, [submitted])67// ✅ Xử lý thẳng trong handler8const handleSubmit = () => {9 sendForm(data)10}
React docs gần đây cũng nhấn mạnh điều này useEffect là để sync với external system (API, DOM, subscription), không phải để react theo user action.

useRef mutable container không ai nói cho bạn nghe
useRef không chỉ để grab DOM element. Đây là cách tạo một container mutable tồn tại suốt lifecycle của component mà không trigger re-render khi thay đổi.
1// DOM ref2const inputRef = useRef(null)3<input ref={inputRef} />4inputRef.current.focus()56// Lưu timer ID không cần re-render khi clear7const timerRef = useRef(null)8useEffect(() => {9 timerRef.current = setInterval(tick, 1000)10 return () => clearInterval(timerRef.current)11}, [])1213// Track previous value14const prevCount = useRef(count)15useEffect(() => {16 prevCount.current = count17})
Mình hay dùng ref để lưu previous value khi cần so sánh giữa render trước và render hiện tại cái này docs ít nhắc đến nhưng thực tế dùng khá nhiều.
Một pattern nâng cao hơn là callback ref thay vì truyền useRef() object, bạn truyền một function:
1const callbackRef = useCallback(node => {2 if (node) {3 // node vừa mount vào DOM4 node.focus()5 }6}, [])78<input ref={callbackRef} />
Dùng khi bạn cần biết chính xác lúc element xuất hiện hoặc biến mất khỏi DOM regular ref không cho bạn biết điều này.
useContext đừng lạm dụng
Context giải quyết prop drilling rất tốt, nhưng cái giá phải trả là performance nếu không cẩn thận.
1const ThemeContext = createContext(null)23function App() {4 const [theme, setTheme] = useState('dark')5 return (6 <ThemeContext.Provider value={{ theme, setTheme }}>7 <Tree />8 </ThemeContext.Provider>9 )10}1112function Button() {13 const { theme } = useContext(ThemeContext)14 return <button className={theme}>Click</button>15}
Vấn đề: mọi consumer đều re-render khi context value thay đổi, kể cả component chỉ dùng theme nhưng setTheme thay đổi reference.
Cách fix:
Theo kinh nghiệm của mình, context phù hợp cho những thứ ít thay đổi như theme, locale, user info. Còn shopping cart, filter state, hay bất cứ thứ gì update thường xuyên thì nên dùng state management library.
| Vấn đề | Giải pháp |
|---|---|
| Context value thay đổi reference mỗi render | `useMemo` cho value object |
| Một context chứa quá nhiều thứ | Split thành nhiều context nhỏ |
| State phức tạp, nhiều component cần access | Chuyển sang Zustand/Jotai |
useReducer khi useState bắt đầu trở nên lộn xộn
Dấu hiệu bạn cần useReducer: bạn có 3-4 state liên quan nhau và logic update bắt đầu lặp lại ở nhiều chỗ.
1const initialState = { items: [], loading: false, error: null }23function reducer(state, action) {4 switch (action.type) {5 case 'FETCH_START':6 return { ...state, loading: true, error: null }7 case 'FETCH_SUCCESS':8 return { ...state, loading: false, items: action.payload }9 case 'FETCH_ERROR':10 return { ...state, loading: false, error: action.payload }11 default:12 return state13 }14}1516const [state, dispatch] = useReducer(reducer, initialState)1718// Dùng19dispatch({ type: 'FETCH_START' })
Lợi ích rõ nhất là reducer function thuần túy, dễ test độc lập mà không cần render component. Bạn chỉ cần test reducer(state, action) trả về đúng state mới là xong.
So sánh nhanh:
| useState | useReducer | |
|---|---|---|
| Phù hợp khi | State đơn giản, ít liên quan | State phức tạp, nhiều sub-values |
| Logic update | Inline trong component | Tập trung trong reducer |
| Testability | Phải test qua component | Test function thuần túy |
| Boilerplate | Ít | Nhiều hơn |
React 19 hooks những thứ đáng để thử ngay
useId
Cái này nhỏ nhưng giải quyết một vấn đề SSR thực tế generate ID unique mà consistent giữa server và client:
1function FormField({ label }) {2 const id = useId()3 return (4 <>5 <label htmlFor={id}>{label}</label>6 <input id={id} />7 </>8 )9}
Trước đây mình hay dùng Math.random() hoặc counter tự tạo, nhưng cả hai đều có thể gây hydration mismatch. useId fix hẳn vấn đề này.
useTransition
Dùng để mark một state update là non-urgent React sẽ ưu tiên render những thứ quan trọng hơn trước:
1const [isPending, startTransition] = useTransition()23const handleSearch = (query) => {4 setInputValue(query) // urgent update input ngay5 startTransition(() => {6 setSearchResults(filterData(query)) // non-urgent có thể defer7 })8}910return (11 <>12 <input value={inputValue} onChange={e => handleSearch(e.target.value)} />13 {isPending ? <Spinner /> : <Results data={searchResults} />}14 </>15)
Mình thấy cái này hay ở chỗ: bạn không cần debounce thủ công nữa cho nhiều use case. React tự biết ưu tiên cái gì.
useDeferredValue
Tương tự useTransition nhưng dùng khi bạn không control được chỗ state update xảy ra:
1const deferredQuery = useDeferredValue(query)23// ExpensiveList chỉ re-render khi React rảnh4<ExpensiveList filter={deferredQuery} />
Khác biệt giữa hai cái:
---
| useTransition | useDeferredValue | |
|---|---|---|
| Control | Wrap update trong startTransition | Wrap value nhận vào |
| Dùng khi | Bạn own code update state | State đến từ prop hoặc library khác |
| isPending | Có | Không |
Anh em lưu ý một điều không có hook nào là silver bullet. useEffect không phải chỗ để xử lý mọi side effect, useContext không phải thay thế cho state management, và useReducer không phải lúc nào cũng tốt hơn useState. Hiểu đúng mental model của từng cái mới là quan trọng nhất.
Bài tiếp theo trong series mình sẽ đi vào custom hooks cách extract logic tái sử dụng và một số pattern mình thấy work tốt trong thực tế.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!