[BUG] When testing a `useDebounce` hook in the project, the Claude Code process controlling the VS Code app consumed a massive 12GB of RAM.
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
I am encountering errors related to running test functions that cause VS Code to run out of RAM—not just on Windows, but on macOS as well.
What Should Happen?
`import { useCallback, useEffect, useMemo, useRef, useState } from "react"
/**
- Bộ debounce dùng chung — gom nhiều nhịp gõ thành MỘT lần chạy.
*
- Vì sao cần ở quy mô 10k CCU: mỗi ký tự gõ ra một
queryKeymới là một - request mới. Gõ "nguyen van a" = 12 request, trong đó 11 cái bị chính cái
- sau làm cho vô nghĩa ngay khi vừa bay đi — nhưng vẫn đã tiêu một kết nối
- DB, một suất rate limit và một lượt ghi log. Với 10k người cùng gõ thì đó
- là chênh lệch giữa ~800 và ~10.000 truy vấn/giây trên cùng một bảng.
.claude/rules/performance.mdchốt mức sàn 300ms.
*
- Ba hook, chọn theo nhu cầu:
- -
useDebouncedSearch— dựng sẵn cho ô tìm kiếm (phần lớn trường hợp) - -
useDebouncedValue— đã có sẵn giá trị, chỉ cần bản trễ của nó - -
useDebouncedCallback— cần hoãn một HÀNH ĐỘNG (lưu nháp, gọi API)
*
- Cố ý KHÔNG có tuỳ chọn
leading. Bắn ngay ở ký tự đầu tiên đúng bằng việc - gửi thêm một request cho chuỗi "n" mà chẳng ai muốn tìm — tức là tái tạo lại
- chính vấn đề đang đi chữa. Cần chạy ngay thì gọi
flush().
*/
/** Mức sàn theo .claude/rules/performance.md. */
const DEFAULT_DELAY_MS = 300
/**
- Trần chờ. Debounce thuần có một điểm mù: người gõ nhanh và đều (không nghỉ
- đủ
delayMsgiữa hai phím) sẽ đẩy hạn chót đi mãi và KHÔNG BAO GIỜ bắn — - ô tìm kiếm trông như bị treo cho tới lúc họ nhấc tay.
maxWaitMsbảo đảm - chậm nhất sau ngần này mili-giây là có kết quả, dù gõ liên tục tới đâu.
*/
const DEFAULT_MAX_WAIT_MS = 1000
type DebounceOptions = {
/** Thời gian im lặng trước khi chạy. Mặc định 300ms. */
delayMs?: number
/** Trần chờ tính từ nhịp đầu tiên. Bỏ trống = không có trần. */
maxWaitMs?: number
}
/ -------------------------------------------------------------------------- /
/ useDebouncedCallback /
/ -------------------------------------------------------------------------- /
export type DebouncedCallback<TArgs extends Array<unknown>> = ((
...args: TArgs
) => void) & {
/** Chạy ngay lần gọi đang chờ (nếu có). Dùng cho phím Enter. */
flush: () => void
/** Bỏ lần gọi đang chờ. Dùng khi xoá ô/đóng dialog. */
cancel: () => void
/** Có lần gọi nào đang chờ không. KHÔNG phản ứng — đừng render theo nó. */
isPending: () => boolean
}
/**
- Bọc
fnthành phiên bản hoãn lại. Danh tính của hàm trả về ổn định qua các - lần render, nên truyền xuống component con không phá
memo.
*
fnđược giữ trong ref: luôn chạy bản mới nhất mà không cần dựng lại timer- ở mỗi lần render — đây là chỗ các bản debounce viết vội hay sai, hoặc là
- dính closure cũ, hoặc là reset timer liên tục nên không bao giờ bắn.
*/
export function useDebouncedCallback<TArgs extends Array<unknown>>(
fn: (...args: TArgs) => void,
options: number | DebounceOptions = {}
): DebouncedCallback<TArgs> {
const { delayMs = DEFAULT_DELAY_MS, maxWaitMs } =
typeof options === "number" ? { delayMs: options } : options
const fnRef = useRef(fn)
useEffect(() => {
fnRef.current = fn
})
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingArgsRef = useRef<TArgs | null>(null)
const firstCallAtRef = useRef<number | null>(null)
const cancel = useCallback(() => {
if (timerRef.current !== null) clearTimeout(timerRef.current)
timerRef.current = null
pendingArgsRef.current = null
firstCallAtRef.current = null
}, [])
const invoke = useCallback(() => {
const args = pendingArgsRef.current
if (args === null) return
cancel()
fnRef.current(...args)
}, [cancel])
// Rời màn hình thì BỎ, không flush: bắn một request cho màn hình vừa đóng là
// lãng phí thuần tuý, chưa kể setState sau unmount.
useEffect(() => cancel, [cancel])
return useMemo(() => {
const run = (...args: TArgs) => {
pendingArgsRef.current = args
const now = Date.now()
firstCallAtRef.current ??= now
const deadline =
maxWaitMs === undefined
? now + delayMs
: Math.min(now + delayMs, firstCallAtRef.current + maxWaitMs)
if (timerRef.current !== null) clearTimeout(timerRef.current)
timerRef.current = setTimeout(invoke, Math.max(0, deadline - now))
}
return Object.assign(run, {
flush: invoke,
cancel,
isPending: () => pendingArgsRef.current !== null,
})
}, [cancel, invoke, delayMs, maxWaitMs])
}
/ -------------------------------------------------------------------------- /
/ useDebouncedValue /
/ -------------------------------------------------------------------------- /
type DebouncedValueOptions<T> = DebounceOptions & {
/**
- Giá trị nào thoả vị từ này thì cập nhật NGAY, không chờ. Dùng cho những
- chuyển trạng thái mà độ trễ chỉ gây khó chịu chứ không cứu được request
- nào — điển hình là xoá trắng ô tìm kiếm (kết quả không lọc gần như luôn
- nằm sẵn trong cache).
*/
immediateWhen?: (value: T) => boolean
}
/**
- Trả về bản trễ của
value, chỉ đổi saudelayMskhông có thay đổi mới.
*
- Dùng khi giá trị đã do nơi khác nắm (state URL, form, props). Tự nắm luôn ô
- nhập thì
useDebouncedSearchgọn hơn và có sẵn Enter/Esc.
*/
export function useDebouncedValue<T>(
value: T,
options: number | DebouncedValueOptions<T> = {}
): T {
const {
delayMs = DEFAULT_DELAY_MS,
maxWaitMs,
immediateWhen,
} = typeof options === "number" ? { delayMs: options } : options
const [debounced, setDebounced] = useState(value)
// Bản đã chốt, giữ trong ref để effect so sánh mà không phải phụ thuộc vào
// state (phụ thuộc vào state sẽ làm effect chạy lại ngay sau khi chốt).
const committedRef = useRef(value)
const firstPendingAtRef = useRef<number | null>(null)
const immediateRef = useRef(immediateWhen)
useEffect(() => {
immediateRef.current = immediateWhen
})
useEffect(() => {
// Gõ đi rồi gõ lại về đúng giá trị cũ: không còn gì để chốt, huỷ nhịp chờ.
if (Object.is(value, committedRef.current)) {
firstPendingAtRef.current = null
return
}
const commit = () => {
firstPendingAtRef.current = null
committedRef.current = value
setDebounced(value)
}
if (immediateRef.current?.(value)) {
commit()
return
}
const now = Date.now()
firstPendingAtRef.current ??= now
const deadline =
maxWaitMs === undefined
? now + delayMs
: Math.min(now + delayMs, firstPendingAtRef.current + maxWaitMs)
const timer = setTimeout(commit, Math.max(0, deadline - now))
return () => clearTimeout(timer)
}, [value, delayMs, maxWaitMs])
return debounced
}
/ -------------------------------------------------------------------------- /
/ useDebouncedSearch /
/ -------------------------------------------------------------------------- /
export type UseDebouncedSearchOptions = DebounceOptions & {
/** Giá trị khởi tạo — ví dụ đọc từ query string của URL. */
initialValue?: string
/**
- Xoá trắng ô thì áp dụng ngay thay vì chờ. Mặc định bật: người dùng bấm ✕
- là muốn thấy lại toàn bộ danh sách tức thì.
*/
instantOnClear?: boolean
/** Chạy mỗi khi giá trị được chốt — tiện để đẩy lên state URL. */
onCommit?: (value: string) => void
}
export type UseDebouncedSearch = {
/** Giá trị đang gõ — bind vào input, đổi tức thì. */
value: string
/** Giá trị đã chốt — dùng cho queryKey, đổi có độ trễ. */
debouncedValue: string
/** Đang có nhịp gõ chờ chốt. Dùng để hiện spinner cạnh ô tìm kiếm. */
isDebouncing: boolean
setValue: (next: string) => void
/** Chốt ngay (Enter). */
submit: () => void
/** Xoá trắng và chốt ngay (Esc / nút ✕). */
clear: () => void
/** Bung thẳng vào <Input {...inputProps} />. */
inputProps: {
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
}
/**
- Ô tìm kiếm hoàn chỉnh: giữ hai giá trị — bản đang gõ (hiện lên UI ngay, ô
- nhập không bao giờ giật) và bản đã chốt (đưa vào
queryKey).
*
- ```tsx
- const search = useDebouncedSearch({ initialValue: getString("q") })
- const { data } = useQuery(usersQuery({ q: search.debouncedValue }))
- return <Input {...search.inputProps} placeholder={m.dt_search_placeholder()} />
- ```
*
- Enter chốt ngay, Esc xoá trắng — hai phím này người dùng mặc định là có, và
- thiếu chúng thì 300ms độ trễ bị đọc thành "trang bị lag".
*/
export function useDebouncedSearch(
options: UseDebouncedSearchOptions = {}
): UseDebouncedSearch {
const {
initialValue = "",
delayMs = DEFAULT_DELAY_MS,
maxWaitMs = DEFAULT_MAX_WAIT_MS,
instantOnClear = true,
onCommit,
} = options
const [draft, setDraft] = useState(initialValue)
const [committed, setCommitted] = useState(initialValue)
const onCommitRef = useRef(onCommit)
useEffect(() => {
onCommitRef.current = onCommit
})
const commitNow = useCallback((next: string) => {
setCommitted(next)
onCommitRef.current?.(next)
}, [])
const commitDebounced = useDebouncedCallback(commitNow, {
delayMs,
maxWaitMs,
})
const setValue = useCallback(
(next: string) => {
setDraft(next)
// Chỉ bắt đúng chuỗi rỗng, không trim: " " vẫn là một từ khoá người dùng
// đang gõ dở và phải được đệm như mọi từ khoá khác. Việc trim trước khi
// gửi lên server là chuyện của nơi gọi.
if (instantOnClear && next === "") {
commitDebounced.cancel()
commitNow("")
return
}
commitDebounced(next)
},
[commitDebounced, commitNow, instantOnClear]
)
const submit = useCallback(() => commitDebounced.flush(), [commitDebounced])
const clear = useCallback(() => {
setDraft("")
commitDebounced.cancel()
commitNow("")
}, [commitDebounced, commitNow])
const inputProps = useMemo(
() => ({
value: draft,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setValue(e.target.value),
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault()
submit()
} else if (e.key === "Escape") {
clear()
}
},
}),
[draft, setValue, submit, clear]
)
return {
value: draft,
debouncedValue: committed,
// Suy ra chứ không nuôi thêm một state nữa: hai giá trị lệch nhau CHÍNH LÀ
// định nghĩa của "đang chờ chốt", nên không có cách nào lệch pha.
isDebouncing: draft !== committed,
setValue,
submit,
clear,
inputProps,
}
}
`
`// @vitest-environment jsdom
// Cái dễ hỏng ở debounce không phải "có trễ hay không" — sai kiểu đó lộ ra
// ngay khi mở màn hình. Thứ hỏng âm thầm là: bắn ĐÚNG MỘT lần hay bắn thêm
// một lần thừa với giá trị cũ, và có bắn nổi không khi người dùng gõ liên tục
// không nghỉ. Cả hai đều trông bình thường trên UI mà lại nhân đôi tải DB.
import { act, cleanup, renderHook } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
useDebouncedCallback,
useDebouncedSearch,
useDebouncedValue,
} from "./use-debounce"
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
/** Đẩy đồng hồ trong act để React kịp xả state do timer sinh ra. */
function advance(ms: number) {
act(() => {
vi.advanceTimersByTime(ms)
})
}
describe("useDebouncedCallback", () => {
it("gom nhiều lần gọi liên tiếp thành một, với đối số cuối cùng", () => {
const fn = vi.fn()
const { result } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current("n")
result.current("ng")
result.current("nguyen")
})
expect(fn).not.toHaveBeenCalled()
advance(300)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith("nguyen")
})
it("flush() chạy ngay lần đang chờ và không bắn lại khi timer đáo hạn", () => {
const fn = vi.fn()
const { result } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current("nguyen")
result.current.flush()
})
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith("nguyen")
// Enter xong mà timer cũ vẫn nổ thì thành hai request cho một từ khoá.
advance(1000)
expect(fn).toHaveBeenCalledTimes(1)
})
it("flush() khi không có gì đang chờ thì không gọi hàm", () => {
const fn = vi.fn()
const { result } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current.flush()
})
expect(fn).not.toHaveBeenCalled()
})
it("cancel() bỏ hẳn lần đang chờ", () => {
const fn = vi.fn()
const { result } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current("nguyen")
result.current.cancel()
})
advance(1000)
expect(fn).not.toHaveBeenCalled()
})
it("maxWaitMs bắn được cả khi gõ liên tục không nghỉ", () => {
const fn = vi.fn()
const { result } = renderHook(() =>
useDebouncedCallback(fn, { delayMs: 300, maxWaitMs: 1000 })
)
// Cứ 200ms một phím: debounce thuần sẽ dời hạn chót mãi mãi.
for (let i = 0; i < 10; i++) {
act(() => {
result.current(ky-tu-${i})
})
advance(200)
}
expect(fn).toHaveBeenCalled()
})
it("unmount thì bỏ lần đang chờ, không bắn request cho màn hình đã đóng", () => {
const fn = vi.fn()
const { result, unmount } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current("nguyen")
})
unmount()
advance(1000)
expect(fn).not.toHaveBeenCalled()
})
it("luôn chạy bản mới nhất của hàm, không dính closure cũ", () => {
const first = vi.fn()
const second = vi.fn()
const { result, rerender } = renderHook(
({ fn }: { fn: () => void }) => useDebouncedCallback(fn, 300),
{ initialProps: { fn: first } }
)
act(() => {
result.current()
})
rerender({ fn: second })
advance(300)
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledTimes(1)
})
it("giữ nguyên danh tính hàm qua các lần render", () => {
const { result, rerender } = renderHook(() =>
useDebouncedCallback(() => {}, 300)
)
const before = result.current
rerender()
expect(result.current).toBe(before)
})
})
describe("useDebouncedValue", () => {
it("chỉ đổi sau khi hết thời gian im lặng", () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => useDebouncedValue(value, 300),
{ initialProps: { value: "" } }
)
rerender({ value: "ngu" })
expect(result.current).toBe("")
advance(299)
expect(result.current).toBe("")
advance(1)
expect(result.current).toBe("ngu")
})
it("gõ rồi xoá về đúng giá trị cũ thì không tạo ra nhịp đổi nào", () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => useDebouncedValue(value, 300),
{ initialProps: { value: "a" } }
)
rerender({ value: "ab" })
rerender({ value: "a" })
advance(1000)
expect(result.current).toBe("a")
})
it("immediateWhen bỏ qua độ trễ cho giá trị được chỉ định", () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) =>
useDebouncedValue(value, {
delayMs: 300,
immediateWhen: (v) => v === "",
}),
{ initialProps: { value: "nguyen" } }
)
rerender({ value: "" })
expect(result.current).toBe("")
})
})
describe("useDebouncedSearch", () => {
it("ô nhập đổi tức thì, giá trị chốt thì có độ trễ", () => {
const { result } = renderHook(() => useDebouncedSearch())
act(() => {
result.current.setValue("ngu")
})
expect(result.current.value).toBe("ngu")
expect(result.current.debouncedValue).toBe("")
expect(result.current.isDebouncing).toBe(true)
advance(300)
expect(result.current.debouncedValue).toBe("ngu")
expect(result.current.isDebouncing).toBe(false)
})
it("onCommit chỉ chạy một lần cho cả một tràng gõ", () => {
const onCommit = vi.fn()
const { result } = renderHook(() => useDebouncedSearch({ onCommit }))
act(() => {
result.current.setValue("n")
})
act(() => {
result.current.setValue("ng")
})
act(() => {
result.current.setValue("nguyen")
})
advance(300)
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenCalledWith("nguyen")
})
it("Enter chốt ngay không cần chờ", () => {
const { result } = renderHook(() => useDebouncedSearch())
act(() => {
result.current.setValue("nguyen")
})
act(() => {
result.current.inputProps.onKeyDown({
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>)
})
expect(result.current.debouncedValue).toBe("nguyen")
})
it("xoá trắng ô thì áp dụng ngay, không bắt người dùng chờ thêm 300ms", () => {
const { result } = renderHook(() => useDebouncedSearch())
act(() => {
result.current.setValue("nguyen")
})
advance(300)
act(() => {
result.current.setValue("")
})
expect(result.current.debouncedValue).toBe("")
expect(result.current.isDebouncing).toBe(false)
})
it("Esc xoá cả ô nhập lẫn giá trị đã chốt", () => {
const { result } = renderHook(() => useDebouncedSearch())
act(() => {
result.current.setValue("nguyen")
})
advance(300)
act(() => {
result.current.inputProps.onKeyDown({
key: "Escape",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>)
})
expect(result.current.value).toBe("")
expect(result.current.debouncedValue).toBe("")
})
it("xoá trắng rồi gõ tiếp không làm nhịp chờ cũ chốt đè lên", () => {
const onCommit = vi.fn()
const { result } = renderHook(() => useDebouncedSearch({ onCommit }))
act(() => {
result.current.setValue("nguyen")
})
act(() => {
result.current.setValue("")
})
advance(1000)
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenLastCalledWith("")
expect(result.current.debouncedValue).toBe("")
})
it("nhận giá trị khởi tạo mà không sinh nhịp chờ giả", () => {
const { result } = renderHook(() =>
useDebouncedSearch({ initialValue: "nguyen" })
)
expect(result.current.value).toBe("nguyen")
expect(result.current.debouncedValue).toBe("nguyen")
expect(result.current.isDebouncing).toBe(false)
})
})
`
Error Messages/Logs
Steps to Reproduce
1.This is Code
`import { useCallback, useEffect, useMemo, useRef, useState } from "react"
/**
- Bộ debounce dùng chung — gom nhiều nhịp gõ thành MỘT lần chạy.
*
- Vì sao cần ở quy mô 10k CCU: mỗi ký tự gõ ra một
queryKeymới là một - request mới. Gõ "nguyen van a" = 12 request, trong đó 11 cái bị chính cái
- sau làm cho vô nghĩa ngay khi vừa bay đi — nhưng vẫn đã tiêu một kết nối
- DB, một suất rate limit và một lượt ghi log. Với 10k người cùng gõ thì đó
- là chênh lệch giữa ~800 và ~10.000 truy vấn/giây trên cùng một bảng.
.claude/rules/performance.mdchốt mức sàn 300ms.
*
- Ba hook, chọn theo nhu cầu:
- -
useDebouncedSearch— dựng sẵn cho ô tìm kiếm (phần lớn trường hợp) - -
useDebouncedValue— đã có sẵn giá trị, chỉ cần bản trễ của nó - -
useDebouncedCallback— cần hoãn một HÀNH ĐỘNG (lưu nháp, gọi API)
*
- Cố ý KHÔNG có tuỳ chọn
leading. Bắn ngay ở ký tự đầu tiên đúng bằng việc - gửi thêm một request cho chuỗi "n" mà chẳng ai muốn tìm — tức là tái tạo lại
- chính vấn đề đang đi chữa. Cần chạy ngay thì gọi
flush().
*/
/** Mức sàn theo .claude/rules/performance.md. */
const DEFAULT_DELAY_MS = 300
/**
- Trần chờ. Debounce thuần có một điểm mù: người gõ nhanh và đều (không nghỉ
- đủ
delayMsgiữa hai phím) sẽ đẩy hạn chót đi mãi và KHÔNG BAO GIỜ bắn — - ô tìm kiếm trông như bị treo cho tới lúc họ nhấc tay.
maxWaitMsbảo đảm - chậm nhất sau ngần này mili-giây là có kết quả, dù gõ liên tục tới đâu.
*/
const DEFAULT_MAX_WAIT_MS = 1000
type DebounceOptions = {
/** Thời gian im lặng trước khi chạy. Mặc định 300ms. */
delayMs?: number
/** Trần chờ tính từ nhịp đầu tiên. Bỏ trống = không có trần. */
maxWaitMs?: number
}
/ -------------------------------------------------------------------------- /
/ useDebouncedCallback /
/ -------------------------------------------------------------------------- /
export type DebouncedCallback<TArgs extends Array<unknown>> = ((
...args: TArgs
) => void) & {
/** Chạy ngay lần gọi đang chờ (nếu có). Dùng cho phím Enter. */
flush: () => void
/** Bỏ lần gọi đang chờ. Dùng khi xoá ô/đóng dialog. */
cancel: () => void
/** Có lần gọi nào đang chờ không. KHÔNG phản ứng — đừng render theo nó. */
isPending: () => boolean
}
/**
- Bọc
fnthành phiên bản hoãn lại. Danh tính của hàm trả về ổn định qua các - lần render, nên truyền xuống component con không phá
memo.
*
fnđược giữ trong ref: luôn chạy bản mới nhất mà không cần dựng lại timer- ở mỗi lần render — đây là chỗ các bản debounce viết vội hay sai, hoặc là
- dính closure cũ, hoặc là reset timer liên tục nên không bao giờ bắn.
*/
export function useDebouncedCallback<TArgs extends Array<unknown>>(
fn: (...args: TArgs) => void,
options: number | DebounceOptions = {}
): DebouncedCallback<TArgs> {
const { delayMs = DEFAULT_DELAY_MS, maxWaitMs } =
typeof options === "number" ? { delayMs: options } : options
const fnRef = useRef(fn)
useEffect(() => {
fnRef.current = fn
})
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingArgsRef = useRef<TArgs | null>(null)
const firstCallAtRef = useRef<number | null>(null)
const cancel = useCallback(() => {
if (timerRef.current !== null) clearTimeout(timerRef.current)
timerRef.current = null
pendingArgsRef.current = null
firstCallAtRef.current = null
}, [])
const invoke = useCallback(() => {
const args = pendingArgsRef.current
if (args === null) return
cancel()
fnRef.current(...args)
}, [cancel])
// Rời màn hình thì BỎ, không flush: bắn một request cho màn hình vừa đóng là
// lãng phí thuần tuý, chưa kể setState sau unmount.
useEffect(() => cancel, [cancel])
return useMemo(() => {
const run = (...args: TArgs) => {
pendingArgsRef.current = args
const now = Date.now()
firstCallAtRef.current ??= now
const deadline =
maxWaitMs === undefined
? now + delayMs
: Math.min(now + delayMs, firstCallAtRef.current + maxWaitMs)
if (timerRef.current !== null) clearTimeout(timerRef.current)
timerRef.current = setTimeout(invoke, Math.max(0, deadline - now))
}
return Object.assign(run, {
flush: invoke,
cancel,
isPending: () => pendingArgsRef.current !== null,
})
}, [cancel, invoke, delayMs, maxWaitMs])
}
/ -------------------------------------------------------------------------- /
/ useDebouncedValue /
/ -------------------------------------------------------------------------- /
type DebouncedValueOptions<T> = DebounceOptions & {
/**
- Giá trị nào thoả vị từ này thì cập nhật NGAY, không chờ. Dùng cho những
- chuyển trạng thái mà độ trễ chỉ gây khó chịu chứ không cứu được request
- nào — điển hình là xoá trắng ô tìm kiếm (kết quả không lọc gần như luôn
- nằm sẵn trong cache).
*/
immediateWhen?: (value: T) => boolean
}
/**
- Trả về bản trễ của
value, chỉ đổi saudelayMskhông có thay đổi mới.
*
- Dùng khi giá trị đã do nơi khác nắm (state URL, form, props). Tự nắm luôn ô
- nhập thì
useDebouncedSearchgọn hơn và có sẵn Enter/Esc.
*/
export function useDebouncedValue<T>(
value: T,
options: number | DebouncedValueOptions<T> = {}
): T {
const {
delayMs = DEFAULT_DELAY_MS,
maxWaitMs,
immediateWhen,
} = typeof options === "number" ? { delayMs: options } : options
const [debounced, setDebounced] = useState(value)
// Bản đã chốt, giữ trong ref để effect so sánh mà không phải phụ thuộc vào
// state (phụ thuộc vào state sẽ làm effect chạy lại ngay sau khi chốt).
const committedRef = useRef(value)
const firstPendingAtRef = useRef<number | null>(null)
const immediateRef = useRef(immediateWhen)
useEffect(() => {
immediateRef.current = immediateWhen
})
useEffect(() => {
// Gõ đi rồi gõ lại về đúng giá trị cũ: không còn gì để chốt, huỷ nhịp chờ.
if (Object.is(value, committedRef.current)) {
firstPendingAtRef.current = null
return
}
const commit = () => {
firstPendingAtRef.current = null
committedRef.current = value
setDebounced(value)
}
if (immediateRef.current?.(value)) {
commit()
return
}
const now = Date.now()
firstPendingAtRef.current ??= now
const deadline =
maxWaitMs === undefined
? now + delayMs
: Math.min(now + delayMs, firstPendingAtRef.current + maxWaitMs)
const timer = setTimeout(commit, Math.max(0, deadline - now))
return () => clearTimeout(timer)
}, [value, delayMs, maxWaitMs])
return debounced
}
/ -------------------------------------------------------------------------- /
/ useDebouncedSearch /
/ -------------------------------------------------------------------------- /
export type UseDebouncedSearchOptions = DebounceOptions & {
/** Giá trị khởi tạo — ví dụ đọc từ query string của URL. */
initialValue?: string
/**
- Xoá trắng ô thì áp dụng ngay thay vì chờ. Mặc định bật: người dùng bấm ✕
- là muốn thấy lại toàn bộ danh sách tức thì.
*/
instantOnClear?: boolean
/** Chạy mỗi khi giá trị được chốt — tiện để đẩy lên state URL. */
onCommit?: (value: string) => void
}
export type UseDebouncedSearch = {
/** Giá trị đang gõ — bind vào input, đổi tức thì. */
value: string
/** Giá trị đã chốt — dùng cho queryKey, đổi có độ trễ. */
debouncedValue: string
/** Đang có nhịp gõ chờ chốt. Dùng để hiện spinner cạnh ô tìm kiếm. */
isDebouncing: boolean
setValue: (next: string) => void
/** Chốt ngay (Enter). */
submit: () => void
/** Xoá trắng và chốt ngay (Esc / nút ✕). */
clear: () => void
/** Bung thẳng vào <Input {...inputProps} />. */
inputProps: {
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
}
/**
- Ô tìm kiếm hoàn chỉnh: giữ hai giá trị — bản đang gõ (hiện lên UI ngay, ô
- nhập không bao giờ giật) và bản đã chốt (đưa vào
queryKey).
*
- ```tsx
- const search = useDebouncedSearch({ initialValue: getString("q") })
- const { data } = useQuery(usersQuery({ q: search.debouncedValue }))
- return <Input {...search.inputProps} placeholder={m.dt_search_placeholder()} />
- ```
*
- Enter chốt ngay, Esc xoá trắng — hai phím này người dùng mặc định là có, và
- thiếu chúng thì 300ms độ trễ bị đọc thành "trang bị lag".
*/
export function useDebouncedSearch(
options: UseDebouncedSearchOptions = {}
): UseDebouncedSearch {
const {
initialValue = "",
delayMs = DEFAULT_DELAY_MS,
maxWaitMs = DEFAULT_MAX_WAIT_MS,
instantOnClear = true,
onCommit,
} = options
const [draft, setDraft] = useState(initialValue)
const [committed, setCommitted] = useState(initialValue)
const onCommitRef = useRef(onCommit)
useEffect(() => {
onCommitRef.current = onCommit
})
const commitNow = useCallback((next: string) => {
setCommitted(next)
onCommitRef.current?.(next)
}, [])
const commitDebounced = useDebouncedCallback(commitNow, {
delayMs,
maxWaitMs,
})
const setValue = useCallback(
(next: string) => {
setDraft(next)
// Chỉ bắt đúng chuỗi rỗng, không trim: " " vẫn là một từ khoá người dùng
// đang gõ dở và phải được đệm như mọi từ khoá khác. Việc trim trước khi
// gửi lên server là chuyện của nơi gọi.
if (instantOnClear && next === "") {
commitDebounced.cancel()
commitNow("")
return
}
commitDebounced(next)
},
[commitDebounced, commitNow, instantOnClear]
)
const submit = useCallback(() => commitDebounced.flush(), [commitDebounced])
const clear = useCallback(() => {
setDraft("")
commitDebounced.cancel()
commitNow("")
}, [commitDebounced, commitNow])
const inputProps = useMemo(
() => ({
value: draft,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setValue(e.target.value),
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault()
submit()
} else if (e.key === "Escape") {
clear()
}
},
}),
[draft, setValue, submit, clear]
)
return {
value: draft,
debouncedValue: committed,
// Suy ra chứ không nuôi thêm một state nữa: hai giá trị lệch nhau CHÍNH LÀ
// định nghĩa của "đang chờ chốt", nên không có cách nào lệch pha.
isDebouncing: draft !== committed,
setValue,
submit,
clear,
inputProps,
}
}
`
2. This is test
`// @vitest-environment jsdom
// Cái dễ hỏng ở debounce không phải "có trễ hay không" — sai kiểu đó lộ ra
// ngay khi mở màn hình. Thứ hỏng âm thầm là: bắn ĐÚNG MỘT lần hay bắn thêm
// một lần thừa với giá trị cũ, và có bắn nổi không khi người dùng gõ liên tục
// không nghỉ. Cả hai đều trông bình thường trên UI mà lại nhân đôi tải DB.
import { act, cleanup, renderHook } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
useDebouncedCallback,
useDebouncedSearch,
useDebouncedValue,
} from "./use-debounce"
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
/** Đẩy đồng hồ trong act để React kịp xả state do timer sinh ra. */
function advance(ms: number) {
act(() => {
vi.advanceTimersByTime(ms)
})
}
describe("useDebouncedCallback", () => {
it("gom nhiều lần gọi liên tiếp thành một, với đối số cuối cùng", () => {
const fn = vi.fn()
const { result } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current("n")
result.current("ng")
result.current("nguyen")
})
expect(fn).not.toHaveBeenCalled()
advance(300)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith("nguyen")
})
it("flush() chạy ngay lần đang chờ và không bắn lại khi timer đáo hạn", () => {
const fn = vi.fn()
const { result } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current("nguyen")
result.current.flush()
})
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith("nguyen")
// Enter xong mà timer cũ vẫn nổ thì thành hai request cho một từ khoá.
advance(1000)
expect(fn).toHaveBeenCalledTimes(1)
})
it("flush() khi không có gì đang chờ thì không gọi hàm", () => {
const fn = vi.fn()
const { result } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current.flush()
})
expect(fn).not.toHaveBeenCalled()
})
it("cancel() bỏ hẳn lần đang chờ", () => {
const fn = vi.fn()
const { result } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current("nguyen")
result.current.cancel()
})
advance(1000)
expect(fn).not.toHaveBeenCalled()
})
it("maxWaitMs bắn được cả khi gõ liên tục không nghỉ", () => {
const fn = vi.fn()
const { result } = renderHook(() =>
useDebouncedCallback(fn, { delayMs: 300, maxWaitMs: 1000 })
)
// Cứ 200ms một phím: debounce thuần sẽ dời hạn chót mãi mãi.
for (let i = 0; i < 10; i++) {
act(() => {
result.current(ky-tu-${i})
})
advance(200)
}
expect(fn).toHaveBeenCalled()
})
it("unmount thì bỏ lần đang chờ, không bắn request cho màn hình đã đóng", () => {
const fn = vi.fn()
const { result, unmount } = renderHook(() => useDebouncedCallback(fn, 300))
act(() => {
result.current("nguyen")
})
unmount()
advance(1000)
expect(fn).not.toHaveBeenCalled()
})
it("luôn chạy bản mới nhất của hàm, không dính closure cũ", () => {
const first = vi.fn()
const second = vi.fn()
const { result, rerender } = renderHook(
({ fn }: { fn: () => void }) => useDebouncedCallback(fn, 300),
{ initialProps: { fn: first } }
)
act(() => {
result.current()
})
rerender({ fn: second })
advance(300)
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledTimes(1)
})
it("giữ nguyên danh tính hàm qua các lần render", () => {
const { result, rerender } = renderHook(() =>
useDebouncedCallback(() => {}, 300)
)
const before = result.current
rerender()
expect(result.current).toBe(before)
})
})
describe("useDebouncedValue", () => {
it("chỉ đổi sau khi hết thời gian im lặng", () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => useDebouncedValue(value, 300),
{ initialProps: { value: "" } }
)
rerender({ value: "ngu" })
expect(result.current).toBe("")
advance(299)
expect(result.current).toBe("")
advance(1)
expect(result.current).toBe("ngu")
})
it("gõ rồi xoá về đúng giá trị cũ thì không tạo ra nhịp đổi nào", () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => useDebouncedValue(value, 300),
{ initialProps: { value: "a" } }
)
rerender({ value: "ab" })
rerender({ value: "a" })
advance(1000)
expect(result.current).toBe("a")
})
it("immediateWhen bỏ qua độ trễ cho giá trị được chỉ định", () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) =>
useDebouncedValue(value, {
delayMs: 300,
immediateWhen: (v) => v === "",
}),
{ initialProps: { value: "nguyen" } }
)
rerender({ value: "" })
expect(result.current).toBe("")
})
})
describe("useDebouncedSearch", () => {
it("ô nhập đổi tức thì, giá trị chốt thì có độ trễ", () => {
const { result } = renderHook(() => useDebouncedSearch())
act(() => {
result.current.setValue("ngu")
})
expect(result.current.value).toBe("ngu")
expect(result.current.debouncedValue).toBe("")
expect(result.current.isDebouncing).toBe(true)
advance(300)
expect(result.current.debouncedValue).toBe("ngu")
expect(result.current.isDebouncing).toBe(false)
})
it("onCommit chỉ chạy một lần cho cả một tràng gõ", () => {
const onCommit = vi.fn()
const { result } = renderHook(() => useDebouncedSearch({ onCommit }))
act(() => {
result.current.setValue("n")
})
act(() => {
result.current.setValue("ng")
})
act(() => {
result.current.setValue("nguyen")
})
advance(300)
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenCalledWith("nguyen")
})
it("Enter chốt ngay không cần chờ", () => {
const { result } = renderHook(() => useDebouncedSearch())
act(() => {
result.current.setValue("nguyen")
})
act(() => {
result.current.inputProps.onKeyDown({
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>)
})
expect(result.current.debouncedValue).toBe("nguyen")
})
it("xoá trắng ô thì áp dụng ngay, không bắt người dùng chờ thêm 300ms", () => {
const { result } = renderHook(() => useDebouncedSearch())
act(() => {
result.current.setValue("nguyen")
})
advance(300)
act(() => {
result.current.setValue("")
})
expect(result.current.debouncedValue).toBe("")
expect(result.current.isDebouncing).toBe(false)
})
it("Esc xoá cả ô nhập lẫn giá trị đã chốt", () => {
const { result } = renderHook(() => useDebouncedSearch())
act(() => {
result.current.setValue("nguyen")
})
advance(300)
act(() => {
result.current.inputProps.onKeyDown({
key: "Escape",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>)
})
expect(result.current.value).toBe("")
expect(result.current.debouncedValue).toBe("")
})
it("xoá trắng rồi gõ tiếp không làm nhịp chờ cũ chốt đè lên", () => {
const onCommit = vi.fn()
const { result } = renderHook(() => useDebouncedSearch({ onCommit }))
act(() => {
result.current.setValue("nguyen")
})
act(() => {
result.current.setValue("")
})
advance(1000)
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCommit).toHaveBeenLastCalledWith("")
expect(result.current.debouncedValue).toBe("")
})
it("nhận giá trị khởi tạo mà không sinh nhịp chờ giả", () => {
const { result } = renderHook(() =>
useDebouncedSearch({ initialValue: "nguyen" })
)
expect(result.current.value).toBe("nguyen")
expect(result.current.debouncedValue).toBe("nguyen")
expect(result.current.isDebouncing).toBe(false)
})
})
`
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
Claude Code for VS Code 2.1.227
Platform
Other
Operating System
Windows
Terminal/Shell
VS Code integrated terminal
Additional Information
_No response_