BlogBlog · react

React hooks nâng cao: custom hook đúng cách, useEffect không bug và các hook mới của React 19

Bỏ useEffect thừa, chặn race condition bằng AbortController, viết useLocalStorage với useSyncExternalStore, dùng đúng useReducer, useRef, useTransition, useEffectEvent và để React Compiler lo phần useMemo/useCallback.

Part of Developer Tips series
March 5, 2026
23 min read
By Leo Pham
reacthooksfrontendtypescriptreact-19
Share this post:

Một màn hình tìm kiếm đơn hàng điển hình mà mình hay gặp khi review code: 4 useEffect, 6 useState, và một bug khó tái hiện. Gõ nhanh "ao thun" thì danh sách thỉnh thoảng hiện kết quả của "ao", vì response cũ về sau response mới. Thêm một effect đồng bộ filteredOrders từ orders nên mỗi lần gõ là hai lần render, và một effect khác reset trang về 1 nhưng lại chạy sau khi request trang cũ đã đi.

Phần lớn bug kiểu này không nằm ở React mà ở cách dùng hook: dùng effect cho việc không phải effect, quên cleanup, hiểu nhầm custom hook là nơi chia sẻ state. Bài này đi qua từng pattern với code chạy được, giải thích vì sao nó đúng và chỗ nào nó vẫn gãy.

Code viết cho React 19.x (bản stable mới nhất lúc cập nhật là 19.3) với TypeScript. Các hook mới cho form và Suspense (use, useActionState, useOptimistic, useFormStatus) mình chỉ tóm tắt, chi tiết nằm ở bài React 19 thực chiến.

TL;DR:

  • Effect chỉ dùng để đồng bộ với hệ thống bên ngoài React. Derived state tính trong render, logic theo hành động đặt trong event handler, reset state bằng key.
  • Mọi effect gọi network phải có cleanup (AbortController hoặc cờ ignore). Khi cần cache, dedupe, retry thì dùng TanStack Query thay vì tự viết.
  • Custom hook chia sẻ logic chứ không chia sẻ state. Đọc API trình duyệt (localStorage, matchMedia) bằng useSyncExternalStore.
  • useEffectEvent (React 19.2) thay cho mẹo "latest ref". React Compiler 1.0 lo phần lớn memo, nhưng chỉ khi code tuân thủ Rules of React mà eslint-plugin-react-hooks kiểm tra.

Bạn có thể không cần useEffect

Quy tắc mình dùng: effect là để đồng bộ component với một hệ thống bên ngoài React như network, DOM API, WebSocket, timer hay một thư viện không viết bằng React. Nếu không chỉ ra được hệ thống bên ngoài đó là gì, gần như chắc chắn bạn không cần effect.

Derived state: tính ngay trong render

import { useEffect, useState } from 'react'

type OrderStatus = 'pending' | 'paid' | 'shipped'
type Order = { id: string; code: string; status: OrderStatus; total: number }
type Props = { orders: Order[]; status: OrderStatus }

// ❌ State thừa + effect đồng bộ: render 2 lần, lần đầu hiển thị dữ liệu cũ
export function OrderListBad({ orders, status }: Props) {
  const [visibleOrders, setVisibleOrders] = useState<Order[]>([])
  useEffect(() => {
    setVisibleOrders(orders.filter((o) => o.status === status))
  }, [orders, status])
  return <ul>{visibleOrders.map((o) => <li key={o.id}>{o.code}</li>)}</ul>
}

// ✅ Tính trong render: luôn khớp với props, không có render thừa
export function OrderList({ orders, status }: Props) {
  const visibleOrders = orders.filter((o) => o.status === status)
  const total = visibleOrders.reduce((sum, o) => sum + o.total, 0)
  return (
    <>
      <p>Tổng: {total.toLocaleString('vi-VN')} đ</p>
      <ul>{visibleOrders.map((o) => <li key={o.id}>{o.code}</li>)}</ul>
    </>
  )
}

Bản thứ nhất render với visibleOrders cũ trước, sau đó effect mới set state và gây thêm một lần render nữa. Người dùng thấy một frame dữ liệu sai, còn bạn tốn gấp đôi công render. Rule react-hooks/set-state-in-effect trong eslint-plugin-react-hooks bản mới sẽ bắt đúng pattern này.

Chỉ khi phép tính thật sự nặng (lọc vài chục nghìn phần tử, parse dữ liệu lớn) mới cần useMemo. Nếu đã bật React Compiler thì nó tự memo phần này, mình nói ở phần sau.

Logic theo hành động: đặt trong event handler

import { useState } from 'react'

type InvoiceDraft = { customerId: string; amount: number }

export function CreateInvoiceButton({ draft, onDone }: { draft: InvoiceDraft; onDone: (msg: string) => void }) {
  const [isSaving, setIsSaving] = useState(false)

  // ✅ Gửi request ngay trong handler: biết chính xác vì sao code chạy
  async function handleClick() {
    setIsSaving(true)
    try {
      const res = await fetch('/api/invoices', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(draft),
      })
      onDone(res.ok ? 'Đã tạo hóa đơn' : `Tạo hóa đơn thất bại (HTTP ${res.status})`)
    } finally {
      setIsSaving(false)
    }
  }

  return <button onClick={handleClick} disabled={isSaving}>Tạo hóa đơn</button>
}

Bản sai thường gặp là set một cờ submitted = true rồi để effect "nghe" cờ đó và gửi POST. Effect chạy vì component render, không phải vì người dùng bấm nút. Trong Strict Mode ở môi trường dev, React mount, unmount rồi mount lại component để kiểm tra cleanup, nên effect kiểu đó có thể bắn POST hai lần. Nếu draft đổi sau khi submit, effect còn chạy lại thêm lần nữa.

Reset state khi đổi entity: dùng key

import { useState } from 'react'

function OrderNotes({ orderId }: { orderId: string }) {
  const [note, setNote] = useState('')
  return (
    <textarea
      aria-label={`Ghi chú cho đơn ${orderId}`}
      value={note}
      onChange={(e) => setNote(e.target.value)}
    />
  )
}

export function OrderDetail({ orderId }: { orderId: string }) {
  // key đổi → React unmount instance cũ, mount instance mới với state sạch
  return <OrderNotes key={orderId} orderId={orderId} />
}

Thay cho useEffect(() => setNote(''), [orderId]), vốn render một lần với ghi chú của đơn cũ rồi mới xóa. Cái giá của key là toàn bộ cây con bị tạo lại, DOM dựng lại và effect con chạy lại. Với cây con nặng như rich text editor hay chart, nếu chỉ cần nhớ ghi chú riêng cho từng đơn thì lưu state theo id (Record<string, string>) ở component cha sẽ rẻ hơn.

Effect cleanup và race condition

Đây là phiên bản tìm kiếm đơn hàng không bị response cũ ghi đè response mới:

import { useEffect, useState } from 'react'

type Order = { id: string; code: string; customerName: string; total: number }
type SearchResult = { query: string; orders: Order[]; error: string | null }

async function fetchOrders(query: string, signal: AbortSignal): Promise<Order[]> {
  const res = await fetch(`/api/orders?q=${encodeURIComponent(query)}`, { signal })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return (await res.json()) as Order[]
}

export function useOrderSearch(query: string) {
  const [result, setResult] = useState<SearchResult>({ query: '', orders: [], error: null })

  useEffect(() => {
    if (query === '') return
    const controller = new AbortController()

    fetchOrders(query, controller.signal)
      .then((orders) => setResult({ query, orders, error: null }))
      .catch((error: unknown) => {
        if (controller.signal.aborted) return // bị hủy có chủ đích, không phải lỗi
        setResult({ query, orders: [], error: error instanceof Error ? error.message : String(error) })
      })

    return () => controller.abort() // query đổi hoặc unmount → hủy request cũ
  }, [query])

  // Derived state: kết quả đang hiển thị không thuộc query hiện tại nghĩa là đang tải
  const isLoading = query !== '' && result.query !== query
  return { ...result, isLoading }
}

Vì sao nó đúng: mỗi lần query đổi, React gọi cleanup của lần chạy trước trước khi chạy effect mới. abort() khiến fetch cũ reject với AbortError, nên .then của nó không bao giờ chạy và không thể ghi đè kết quả mới. Mình cũng không set loading đồng bộ trong effect mà suy ra isLoading từ việc result.query có khớp query không, đúng tinh thần phần trước.

Với SDK không nhận AbortSignal, dùng cờ ignore:

useEffect(() => {
  let ignore = false
  crmClient.getCustomer(customerId).then((customer) => {
    if (!ignore) setCustomer(customer)
  })
  return () => {
    ignore = true
  }
}, [customerId])

Khác biệt quan trọng: cờ ignore chỉ chặn việc set state cũ, request vẫn chạy hết và vẫn tốn băng thông. AbortController thì hủy hẳn request ở phía trình duyệt. Nhưng hủy request không rollback được thao tác server đã làm, nên đừng dựa vào abort cho POST/PUT. Trong dev, bạn sẽ thấy request đầu tiên ở tab Network có trạng thái "canceled". Đó là Strict Mode đang chứng minh cleanup của bạn hoạt động.

Vì sao TanStack Query tồn tại

Đoạn code trên mới chỉ giải quyết race condition. Nó chưa có cache giữa các màn hình, chưa gộp hai component cùng gọi một URL, chưa có retry, chưa refetch khi tab được focus lại, cũng chưa hydrate được dữ liệu từ SSR. Tự viết đủ mấy thứ đó là bạn đang viết lại TanStack Query:

import { keepPreviousData, useQuery } from '@tanstack/react-query'

export function useOrderSearchQuery(query: string) {
  return useQuery({
    queryKey: ['orders', 'search', query],
    queryFn: ({ signal }) => fetchOrders(query, signal), // dùng lại hàm ở trên
    enabled: query.trim() !== '',
    placeholderData: keepPreviousData, // giữ kết quả cũ trong lúc tải query mới
    staleTime: 30_000,
  })
}

Khi queryFn dùng signal, TanStack Query v5 tự hủy request của query không còn ai dùng. Quy tắc của mình: một hai request đơn giản trong một component thì effect + AbortController là đủ. Khi nhiều màn hình đọc cùng dữ liệu server hoặc cần cache/retry, dùng TanStack Query, hoặc chuyển việc đọc dữ liệu lên Server Components nếu đang ở Next.js App Router.

Custom hook chia sẻ logic, không chia sẻ state

Hai component cùng gọi useDebouncedValue thì mỗi component có useState và timer riêng. Custom hook chỉ là cách đóng gói một chuỗi lời gọi hook. Muốn hai component thấy cùng một giá trị thì phải lift state lên cha chung, dùng context hoặc một store bên ngoài.

import { useEffect, useState } from 'react'

export function useDebouncedValue<T>(value: T, delayMs = 300): T {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs)
    return () => clearTimeout(timer) // value đổi trước khi hết delay → hủy timer cũ
  }, [value, delayMs])

  return debounced
}

// Dùng: chỉ gọi API sau khi người dùng ngừng gõ 300ms
// const debouncedQuery = useDebouncedValue(query, 300)
// const { data, isFetching } = useOrderSearchQuery(debouncedQuery)

Edge case hay dính: truyền object literal (useDebouncedValue({ status, page })) thì mỗi render là một object mới, effect chạy lại, timer bị reset liên tục và giá trị không bao giờ "lắng". Hãy debounce giá trị primitive.

useLocalStorage với useSyncExternalStore

localStorage là một external store: nó thay đổi ngoài React, có thể bị tab khác sửa, và không tồn tại trên server. useSyncExternalStore được thiết kế cho đúng trường hợp này:

import { useCallback, useMemo, useSyncExternalStore } from 'react'

const SAME_TAB_EVENT = 'app:local-storage'

function subscribe(onStoreChange: () => void) {
  // 'storage' chỉ bắn ở các tab KHÁC; tab hiện tại cần event tự phát
  window.addEventListener('storage', onStoreChange)
  window.addEventListener(SAME_TAB_EVENT, onStoreChange)
  return () => {
    window.removeEventListener('storage', onStoreChange)
    window.removeEventListener(SAME_TAB_EVENT, onStoreChange)
  }
}

function readRaw(key: string): string | null {
  try {
    return window.localStorage.getItem(key)
  } catch {
    return null // storage bị chặn (chế độ riêng tư, policy của trình duyệt)
  }
}

function parse<T>(raw: string | null, fallback: T): T {
  if (raw === null) return fallback
  try {
    return JSON.parse(raw) as T
  } catch {
    return fallback // dữ liệu hỏng hoặc do code cũ ghi
  }
}

/** initialValue nên là primitive hoặc hằng số khai báo ngoài component. */
export function useLocalStorage<T>(key: string, initialValue: T) {
  const raw = useSyncExternalStore(
    subscribe,
    () => readRaw(key), // getSnapshot: trả về string để so sánh bằng Object.is
    () => null, // getServerSnapshot: server và lần hydrate đầu dùng initialValue
  )

  const value = useMemo(() => parse(raw, initialValue), [raw, initialValue])

  const setValue = useCallback(
    (next: T | ((prev: T) => T)) => {
      const prev = parse(readRaw(key), initialValue)
      const resolved = typeof next === 'function' ? (next as (p: T) => T)(prev) : next
      try {
        window.localStorage.setItem(key, JSON.stringify(resolved))
      } catch (error) {
        console.warn(`Không ghi được localStorage key "${key}"`, error) // ví dụ QuotaExceededError
      }
      window.dispatchEvent(new Event(SAME_TAB_EVENT))
    },
    [key, initialValue],
  )

  return [value, setValue] as const
}

Ba điểm làm hook này đúng:

  1. getSnapshot trả về chuỗi thô thay vì object đã parse. React so sánh snapshot bằng Object.is, nếu mỗi lần gọi trả về object mới thì React coi là store đã đổi và render lặp vô hạn, kèm warning "The result of getSnapshot should be cached". Parse làm trong useMemo, chỉ chạy lại khi chuỗi đổi.
  2. getServerSnapshot trả về null, nên HTML từ server và lần hydrate đầu tiên đều dùng initialValue. Sau hydrate React đọc snapshot thật rồi render lại, không có lỗi hydration mismatch.
  3. Event storage không bắn ở chính tab vừa ghi, nên setValue tự phát một event riêng. Mọi component dùng cùng key trong tab đều cập nhật. Đây là trường hợp custom hook "chia sẻ" được giá trị, nhưng là vì nguồn dữ liệu nằm ngoài React chứ không phải vì hook chia sẻ state.

Cùng khuôn này dùng được cho matchMedia('(prefers-color-scheme: dark)'), navigator.onLine hay một WebSocket client có API subscribe.

useReducer cho chuyển trạng thái phức tạp

Khi có từ ba state trở lên đổi cùng nhau, hoặc state tiếp theo phụ thuộc vào loại sự kiện, useReducer với discriminated union loại bỏ được các trạng thái vô lý:

import { useReducer } from 'react'

type UploadState =
  | { status: 'idle' }
  | { status: 'uploading'; fileName: string; progress: number }
  | { status: 'done'; fileName: string; url: string }
  | { status: 'failed'; fileName: string; error: string }

type UploadAction =
  | { type: 'started'; fileName: string }
  | { type: 'progressed'; progress: number }
  | { type: 'succeeded'; url: string }
  | { type: 'failed'; error: string }
  | { type: 'reset' }

export function uploadReducer(state: UploadState, action: UploadAction): UploadState {
  switch (action.type) {
    case 'started':
      return { status: 'uploading', fileName: action.fileName, progress: 0 }
    case 'progressed':
      return state.status === 'uploading' ? { ...state, progress: action.progress } : state
    case 'succeeded':
      return state.status === 'uploading' ? { status: 'done', fileName: state.fileName, url: action.url } : state
    case 'failed':
      return state.status === 'uploading' ? { status: 'failed', fileName: state.fileName, error: action.error } : state
    case 'reset':
      return { status: 'idle' }
  }
}

export function useInvoiceUpload() {
  return useReducer(uploadReducer, { status: 'idle' })
}

So với bốn useState (isUploading, progress, url, error), bạn không thể vô tình có progress = 40 trong khi url đã có giá trị. Event progressed đến muộn sau khi upload xong sẽ bị bỏ qua thay vì kéo UI ngược về trạng thái đang tải. Reducer là hàm thuần nên test bằng Vitest không cần render gì: expect(uploadReducer({ status: 'idle' }, { type: 'progressed', progress: 50 })).toEqual({ status: 'idle' }). dispatch có identity ổn định, truyền xuống component con hay dùng trong effect đều không cần useCallback.

useRef: giá trị mutable và callback ref

useRef dành cho giá trị cần sống qua các lần render nhưng không ảnh hưởng tới UI: timer id, AbortController của lần upload hiện tại, instance của thư viện bên ngoài. Ghi và đọc ref.current trong event handler hoặc effect là được. Không đọc hay ghi nó trong lúc render, vì React không biết khi nào ref đổi để render lại, và React Compiler sẽ bỏ qua component vi phạm (rule react-hooks/refs báo lỗi này).

Với DOM node, callback ref thường tốt hơn useRef + useEffect:

import { useCallback, useState } from 'react'

export function useElementSize<T extends HTMLElement>() {
  const [size, setSize] = useState({ width: 0, height: 0 })

  const ref = useCallback((node: T | null) => {
    if (!node) return
    const observer = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect
      setSize({ width, height })
    })
    observer.observe(node)
    return () => observer.disconnect() // React 19: ref callback được trả về hàm cleanup
  }, [])

  return [ref, size] as const
}

// const [chartRef, { width }] = useElementSize<HTMLDivElement>()
// return <div ref={chartRef}>{width > 0 && <RevenueChart width={width} />}</div>

Lý do: nếu phần tử chỉ render sau khi dữ liệu tải xong, một useEffect(..., []) đã chạy từ trước lúc phần tử tồn tại và ref.current vẫn là null. Callback ref được gọi đúng lúc node gắn vào DOM. Từ React 19, khi ref callback trả về hàm cleanup, React gọi hàm đó lúc node bị gỡ thay vì gọi lại callback với null. Hãy giữ callback ổn định bằng useCallback (hoặc để compiler làm), nếu không React sẽ gỡ và gắn lại ref ở mỗi render.

useTransition và useDeferredValue cho UI mượt

Ô tìm kiếm lọc 20.000 sản phẩm phía client: gõ phím bị khựng vì mỗi ký tự kéo theo một lần render danh sách nặng. useDeferredValue tách phần "phải phản hồi ngay" khỏi phần "có thể đến sau":

import { memo, useDeferredValue, useState } from 'react'

type Product = { id: string; name: string; sku: string }

const ProductResults = memo(function ProductResults({ products, query }: { products: Product[]; query: string }) {
  const q = query.toLowerCase()
  const matches = products.filter((p) => p.name.toLowerCase().includes(q) || p.sku.includes(query))
  return (
    <ul>
      {matches.slice(0, 200).map((p) => (
        <li key={p.id}>{p.name} ({p.sku})</li>
      ))}
    </ul>
  )
})

export function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)
  const isStale = query !== deferredQuery

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Tên hoặc SKU" />
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <ProductResults products={products} query={deferredQuery} />
      </div>
    </>
  )
}

Input luôn cập nhật ngay. Danh sách render với deferredQuery ở nền, và nếu người dùng gõ tiếp thì React bỏ dở lần render đó để làm lần mới. memo là bắt buộc ở đây: thiếu nó, mỗi lần gõ component cha render lại kéo ProductResults render theo với query cũ, công sức bị nhân đôi. Bật React Compiler thì bạn không cần tự bọc memo.

useTransition dùng khi bạn kiểm soát được lời gọi setState, ví dụ chuyển tab:

const [isPending, startTransition] = useTransition()

function selectTab(next: 'orders' | 'invoices' | 'reports') {
  startTransition(() => setTab(next)) // render tab mới ở nền, tab cũ vẫn tương tác được
}

Ba giới hạn cần nhớ. Không bọc cập nhật value của controlled input trong transition, input sẽ lag. useDeferredValue không giảm số request network, gọi API thì vẫn cần debounce. Transition không làm render nhanh hơn, nó chỉ làm render có thể bị ngắt. Từ React 19, useDeferredValue(value, initialValue) nhận thêm giá trị khởi tạo, còn startTransition nhận được async function (là nền tảng của Actions, xem bài React 19).

useEffectEvent (React 19.2): tách phần "sự kiện" khỏi effect

Bài toán kinh điển: effect kết nối WebSocket theo shopId, khi có đơn mới thì hiện thông báo có hoặc không có âm thanh tùy setting. Thêm soundEnabled vào dependency thì mỗi lần bật tắt âm thanh lại reconnect. Bỏ nó ra thì linter báo, và callback đọc giá trị cũ.

import { useEffect, useEffectEvent } from 'react'

type OrderEvent = { orderId: string; status: 'paid' | 'shipped' | 'cancelled' }
type Notify = (event: OrderEvent, options: { sound: boolean }) => void

const REALTIME_URL = process.env.NEXT_PUBLIC_REALTIME_URL ?? 'ws://localhost:4000'

export function useOrderNotifications(shopId: string, soundEnabled: boolean, notify: Notify) {
  // Effect Event luôn "thấy" props/state mới nhất, và không phải dependency
  const onOrderEvent = useEffectEvent((event: OrderEvent) => {
    notify(event, { sound: soundEnabled })
  })

  useEffect(() => {
    const socket = new WebSocket(`${REALTIME_URL}/shops/${shopId}/orders`)
    socket.addEventListener('message', (message) => {
      onOrderEvent(JSON.parse(String(message.data)) as OrderEvent)
    })
    return () => socket.close()
  }, [shopId]) // chỉ reconnect khi đổi shop
}

Luật chơi (linter kiểm tra giúp bạn, cần eslint-plugin-react-hooks bản mới):

  • Không đưa Effect Event vào dependency array.
  • Chỉ gọi nó từ bên trong effect. Không gọi trong lúc render, không truyền xuống component con, không dùng làm onClick.
  • Khai báo trong cùng component hoặc hook với effect dùng nó.
  • Dùng cho logic thật sự là "sự kiện" bắn ra từ effect. Đừng bọc mọi thứ vào useEffectEvent chỉ để im linter, bạn sẽ có bug dữ liệu cũ theo kiểu khác.

Trước 19.2, người ta dùng mẹo lưu notify vào ref rồi cập nhật ref trong useLayoutEffect. Nếu codebase đã lên 19.2+, thay mẹo đó bằng useEffectEvent để linter hiểu ý đồ của bạn.

Các hook mới của React 19: tóm tắt nhanh

Hook Dùng khi Lưu ý chính
use(promise) / use(context) Đọc promise trong render (kết hợp Suspense), đọc context có điều kiện Promise phải được tạo ngoài render hoặc được cache; không đặt trong try/catch
useActionState Quản lý state trả về từ một Action (submit form) Tên cũ là useFormState trong ReactDOM canary
useOptimistic Hiển thị kết quả trước khi server xác nhận Tự quay về giá trị thật khi Action kết thúc
useFormStatus (react-dom) Đọc trạng thái pending của form cha Phải gọi trong component con nằm bên trong <form>
useEffectEvent (19.2) Tách logic "sự kiện" khỏi effect Xem phần trên

Cách các hook này phối hợp với <form action>, Suspense và Server Functions mình viết riêng trong React 19 thực chiến: Actions, useOptimistic, use() và Suspense.

React Compiler: còn cần useMemo/useCallback không?

React Compiler 1.0 ra mắt ngày 7/10/2025. Nó phân tích component và hook lúc build rồi tự chèn memoization, kể cả những chỗ useMemo thủ công không làm được (memo sau một early return chẳng hạn). Với Next.js 16, bật như sau:

npm install --save-dev --save-exact babel-plugin-react-compiler@latest
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  reactCompiler: true, // stable từ Next.js 16, mặc định tắt
}

export default nextConfig

Khuyến nghị chính thức của React team, và cũng là cách mình áp dụng:

  • Code mới: dựa vào compiler, chỉ dùng useMemo/useCallback khi cần kiểm soát chính xác identity, ví dụ một giá trị làm dependency của effect mà bạn không muốn effect chạy lại.
  • Code cũ: giữ nguyên memo hiện có. Xóa memo có thể làm thay đổi output của compiler, nếu muốn xóa thì test kỹ từng chỗ.
  • Compiler bỏ qua những component vi phạm Rules of React: mutate props, đọc ref trong render, gọi hàm có side effect trong render. Nó không báo lỗi runtime, component đó chỉ đơn giản là không được tối ưu. Lint rule là cách duy nhất để biết.
  • Khi debug một component nghi do compiler, thêm directive "use no memo" ở đầu hàm để tạm loại nó ra.

Chi phí: compiler chạy qua Babel nên build chậm hơn. Next.js 16.3 thêm bản Rust thử nghiệm qua experimental.turbopackRustReactCompiler. Trước khi xóa memo hàng loạt, hãy đo bằng React Profiler trên một nhánh riêng. Quy trình đo mình viết trong bài tối ưu hiệu năng React.

eslint-plugin-react-hooks và Rules of Hooks

Hai luật gốc vẫn như cũ. Chỉ gọi hook ở top level của component hoặc custom hook: không trong if, vòng lặp, callback, hay sau một return sớm. Tên custom hook phải bắt đầu bằng use. Lý do là React nhận diện từng hook theo thứ tự gọi. Gọi có điều kiện thì thứ tự lệch giữa các lần render, và state của hook này bị gán cho hook khác.

Bản 7.x của plugin (mới nhất lúc viết là 7.1.1) gộp luôn các rule dựa trên React Compiler vào preset recommended: set-state-in-effect, set-state-in-render, refs, purity, immutability, static-components... Linter không cần bạn cài compiler. Cấu hình flat config:

// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks'
import { defineConfig } from 'eslint/config'

export default defineConfig([
  reactHooks.configs.flat.recommended,
  {
    rules: {
      // Kiểm tra dependency cho cả custom hook nhận deps array
      'react-hooks/exhaustive-deps': ['warn', { additionalHooks: '(useIsomorphicLayoutEffect)' }],
    },
  },
])

Next.js 16 đã bỏ lệnh next lint và next build không còn tự chạy lint, nên hãy chạy eslint . trong CI. Với exhaustive-deps, coi mỗi warning là một bug tiềm ẩn. Cách sửa theo thứ tự ưu tiên: chuyển hàm vào trong effect, dùng functional update setX((prev) => ...), tách phần "sự kiện" ra bằng useEffectEvent, cuối cùng mới là cấu trúc lại component. Một dòng eslint-disable-next-line không kèm giải thích là thứ đầu tiên mình hỏi khi review.

Những lỗi thường gặp

// ❌ 1. Async function làm effect: React nhận về Promise thay vì hàm cleanup
useEffect(async () => {
  setCustomer(await getCustomer(customerId))
}, [customerId])

// ✅ Khai báo async function bên trong, có cleanup
useEffect(() => {
  const controller = new AbortController()
  async function load() {
    const res = await fetch(`/api/customers/${customerId}`, { signal: controller.signal })
    if (res.ok) setCustomer(await res.json())
  }
  load().catch((error) => {
    if (!controller.signal.aborted) console.error(error) // AbortError khi unmount thì bỏ qua
  })
  return () => controller.abort()
}, [customerId])

// ❌ 2. Object tạo mới mỗi render làm dependency → effect chạy lại mỗi render
const filters = { status, page }
useEffect(() => {
  void loadOrders(filters)
}, [filters])

// ✅ Phụ thuộc vào primitive
useEffect(() => {
  void loadOrders({ status, page })
}, [status, page])

// ❌ 3. Stale closure: count luôn là 0 trong closure của interval
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000)
  return () => clearInterval(id)
}, [])

// ✅ Functional update không cần đọc count từ closure
useEffect(() => {
  const id = setInterval(() => setCount((c) => c + 1), 1000)
  return () => clearInterval(id)
}, [])

// ❌ 4. Hook sau early return: thứ tự hook đổi khi order từ null thành có giá trị
function OrderSummary({ order }: { order: Order | null }) {
  if (!order) return null
  const [expanded, setExpanded] = useState(false)
  // ...
}

// ✅ Gọi hook trước mọi return
function OrderSummaryFixed({ order }: { order: Order | null }) {
  const [expanded, setExpanded] = useState(false)
  if (!order) return null
  // ...
}

Thêm hai lỗi không cần code minh họa. Thứ nhất, getSnapshot của useSyncExternalStore trả về object/array mới mỗi lần gọi, gây vòng render vô hạn: hãy trả về primitive hoặc giá trị đã cache. Thứ hai, kỳ vọng hai component gọi cùng một custom hook sẽ thấy cùng state: chúng không thấy, mỗi lần gọi hook là một bản state riêng.

Checklist

  • Với mỗi useEffect trong feature đang làm, trả lời được câu "đồng bộ với hệ thống bên ngoài nào?". Không trả lời được thì chuyển thành tính trong render, event handler hoặc key.
  • Mọi effect gọi network đều có AbortController hoặc cờ ignore trong cleanup.
  • Dữ liệu server được đọc ở từ hai màn hình trở lên chuyển sang TanStack Query (hoặc Server Components).
  • Hook đọc localStorage, matchMedia, online status dùng useSyncExternalStore với getServerSnapshot.
  • State có từ ba field liên quan trở lên chuyển sang useReducer với discriminated union, kèm unit test cho reducer.
  • Không có chỗ nào đọc hoặc ghi ref.current trong lúc render.
  • Nâng eslint-plugin-react-hooks lên 7.x, dùng configs.flat.recommended, chạy eslint . trong CI.
  • Trên React 19.2+, thay mẹo "latest ref" bằng useEffectEvent.
  • Thử reactCompiler: true trên một nhánh, so sánh Profiler trước và sau khi quyết định xóa memo thủ công.

Đọc thêm

Last updated: March 5, 2026

Comments