[BUG] When testing a `useDebounce` hook in the project, the Claude Code process controlling the VS Code app consumed a massive 12GB of RAM.

Status Open
Reported on v2.1.227
Maintainer reply None cached
Activity 0 comments · opened Aug 11, 2026

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 queryKey mớ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.md chố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ỉ
  • đủ delayMs giữ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. maxWaitMs bả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 fn thà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 sau delayMs khô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ì useDebouncedSearch gọ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 queryKey mớ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.md chố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ỉ
  • đủ delayMs giữ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. maxWaitMs bả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 fn thà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 sau delayMs khô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ì useDebouncedSearch gọ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_

View original on GitHub ↗