Как делать12 октября 2026 г.5 мин чтения
JavaScript с нуляШаг 9 из 9

Рецепт: debounce и throttle

Коротко

Debounce откладывает вызов, пока события не прекратятся на заданную паузу, — это поиск по вводу и пересчёт после ресайза. Throttle, пока события идут, пропускает вызов не чаще одного раза за интервал, — это работа с прокруткой. Ниже — типизированные реализации с cancel, flush и опциями leading/trailing, хук useDebouncedValue для React и тесты на поддельных таймерах Vitest.

Рецепт кодаУровень: НачальныйУтилиты JavaScriptПрактика: ~20–40 мин

После материала вы сможете

  • выбрать debounce или throttle под задачу: поиск по вводу, прокрутка, ресайз
  • подключить типизированные debounce и throttle с опциями leading/trailing и методами cancel и flush
  • применить useDebouncedValue и debounce внутри эффекта в React без утечки таймеров
  • проверить поведение во времени тестами Vitest с поддельными таймерами

Проверено на

TypeScript 6.0.3, 7.0.2Vitest 5.0.3jsdom 30.1.2React 19.3.0@testing-library/react 16.3.3ESLint 10.12.0, eslint-plugin-react-hooks 7.1.1Node.js 24 LTS (24.18.0)

Сверено с документацией: 12 октября 2026 г.

Содержание
  1. 01Задача: слишком частые вызовы
  2. 02Реализация: debounce.ts и throttle.ts
  3. 03Как подключить: поиск и прокрутка
  4. 04В React: useDebouncedValue и очистка
  5. 05Как проверить: тесты на поддельных таймерах
  6. 06Когда хватит requestAnimationFrame и другие ограничения

Задача: слишком частые вызовы

Событие input приходит на каждое нажатие клавиши, scroll — по мере прокрутки, resize — пока пользователь тянет край окна. Если на каждое событие отправлять запрос или пересчитывать раскладку, сервер получает лишние запросы, а интерфейс начинает подтормаживать. MDN описывает два способа сократить число вызовов — debounce и throttle; разница в том, что происходит, пока события продолжают идти.

Debounce — дождаться паузы

  • Каждое новое событие отодвигает вызов
  • Пока события идут, вызовов нет вообще
  • Обычно срабатывает в конце серии (trailing), иногда и в начале (leading)
  • Поиск по вводу, автосохранение черновика, пересчёт после ресайза

Throttle — не чаще раза за интервал

  • Вызовы идут и во время серии, но с ограниченной частотой
  • Первый вызов — сразу (leading), последний не теряется (trailing)
  • Подходит, когда нужно поспевать за меняющимся состоянием
  • Позиция прокрутки, отправка координат, прогресс загрузки

debounce(fn, 100)

Когда сработала, мс
340
С каким событием
последним, № 24

debounce(fn, 100, { leading: true })

Когда сработала, мс
0 и 340
С каким событием
первым и последним

throttle(fn, 100)

Когда сработала, мс
0, 100, 200, 300
С каким событием
№ 0, 9, 19, 24
Один и тот же поток: 25 событий каждые 10 мс (0–240 мс), интервал 100 мс. Числа — из теста на поддельных таймерах.

Реализация: debounce.ts и throttle.ts

Обе функции принимают исходную функцию и интервал и возвращают обёртку с теми же аргументами. Тип A extends unknown[] сохраняет сигнатуру: debounce((query: string) => …) не даст вызвать обёртку с числом. Зависимостей нет, код работает и в браузере, и в Node.js.

src/debounce.ts
export type DebounceOptions = {
  leading?: boolean // вызвать сразу на первом вызове серии
  trailing?: boolean // вызвать после паузы с последними аргументами
}

export type Debounced<A extends unknown[]> = ((...args: A) => void) & {
  cancel: () => void // отменить отложенный вызов
  flush: () => void // выполнить отложенный вызов прямо сейчас
  pending: () => boolean
}

export function debounce<A extends unknown[]>(
  fn: (...args: A) => void,
  waitMs: number,
  { leading = false, trailing = true }: DebounceOptions = {},
): Debounced<A> {
  let timer: ReturnType<typeof setTimeout> | undefined
  let lastArgs: A | undefined

  function invoke() {
    const args = lastArgs as A
    lastArgs = undefined
    fn(...args)
  }

  function onPause() {
    if (trailing && lastArgs) {
      invoke()
      // с leading: следующий вызов не сработает сразу, пока не пройдёт ещё одна пауза
      if (leading) {
        timer = setTimeout(onPause, waitMs)
        return
      }
    }
    timer = undefined
    lastArgs = undefined
  }

  const debounced = (...args: A) => {
    const startsSeries = timer === undefined
    lastArgs = args
    clearTimeout(timer)
    timer = setTimeout(onPause, waitMs) // каждый вызов отодвигает срабатывание
    if (leading && startsSeries) invoke()
  }

  debounced.cancel = () => {
    clearTimeout(timer)
    timer = undefined
    lastArgs = undefined
  }

  debounced.flush = () => {
    if (timer === undefined) return
    clearTimeout(timer)
    timer = undefined
    if (lastArgs) invoke()
  }

  debounced.pending = () => timer !== undefined

  return debounced
}
Debounce: срабатывает после паузы waitMs с последними аргументами.
src/throttle.ts
export type ThrottleOptions = {
  leading?: boolean // вызвать сразу на первом вызове
  trailing?: boolean // вызвать в конце окна с последними аргументами
}

export type Throttled<A extends unknown[]> = ((...args: A) => void) & {
  cancel: () => void
  flush: () => void
  pending: () => boolean
}

export function throttle<A extends unknown[]>(
  fn: (...args: A) => void,
  waitMs: number,
  { leading = true, trailing = true }: ThrottleOptions = {},
): Throttled<A> {
  let timer: ReturnType<typeof setTimeout> | undefined
  let lastArgs: A | undefined

  function invoke() {
    const args = lastArgs as A
    lastArgs = undefined
    fn(...args)
  }

  function startWindow() {
    timer = setTimeout(() => {
      if (trailing && lastArgs) {
        invoke()
        startWindow() // вызов в конце окна открывает новое окно
      } else {
        timer = undefined
        lastArgs = undefined
      }
    }, waitMs)
  }

  const throttled = (...args: A) => {
    lastArgs = args
    if (timer !== undefined) return // окно открыто: запомнили аргументы и ждём
    if (leading) invoke()
    startWindow()
  }

  throttled.cancel = () => {
    clearTimeout(timer)
    timer = undefined
    lastArgs = undefined
  }

  throttled.flush = () => {
    if (timer === undefined) return
    clearTimeout(timer)
    timer = undefined
    if (lastArgs) invoke()
  }

  throttled.pending = () => timer !== undefined

  return throttled
}
Throttle: не чаще одного вызова за waitMs, последний вызов серии не теряется.

leading

debounce
по умолчанию false: первый вызов серии — сразу
throttle
по умолчанию true: первый вызов — сразу

trailing

debounce
по умолчанию true: вызов после паузы
throttle
по умолчанию true: вызов в конце окна

cancel()

debounce
забыть отложенный вызов и снять таймер
throttle
то же

flush()

debounce
выполнить отложенный вызов прямо сейчас
throttle
то же, окно закрывается

pending()

debounce
true, пока ждём паузу
throttle
true, пока открыто окно

Как подключить: поиск и прокрутка

Каталог услуг: поле поиска отправляет запрос через 300 мс после последнего нажатия, Enter ищет сразу, а позиция прокрутки сохраняется в sessionStorage, чтобы вернуть посетителя на то же место. Разметка — форма #search-form с полем q и список #results.

src/catalog.ts
import { debounce } from './debounce'
import { throttle } from './throttle'

const form = document.querySelector<HTMLFormElement>('#search-form')!
const input = form.querySelector<HTMLInputElement>('input[name="q"]')!
const list = document.querySelector<HTMLUListElement>('#results')!
let controller: AbortController | undefined

function item(text: string) {
  const li = document.createElement('li')
  li.textContent = text
  return li
}

async function search(query: string) {
  controller?.abort() // ответ на старый запрос больше не нужен
  if (query === '') {
    list.replaceChildren()
    return
  }
  const current = new AbortController()
  controller = current
  try {
    const response = await fetch(`/api/services?q=${encodeURIComponent(query)}`, { signal: current.signal })
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    const names: string[] = await response.json()
    list.replaceChildren(...names.map(item))
  } catch {
    if (current.signal.aborted) return // отменён более свежим запросом — это норма
    list.replaceChildren(item('Не удалось загрузить список'))
  }
}

// Поиск: запрос уходит через 300 мс после последнего нажатия
const debouncedSearch = debounce((query: string) => void search(query), 300)
input.addEventListener('input', () => debouncedSearch(input.value.trim()))
form.addEventListener('submit', (event) => {
  event.preventDefault()
  debouncedSearch.flush() // Enter — искать сразу, не дожидаясь паузы
})

// Позиция прокрутки каталога: сохраняем не чаще раза в 250 мс
const saveScroll = throttle(() => {
  sessionStorage.setItem('catalog-scroll', String(window.scrollY))
}, 250)
window.addEventListener('scroll', saveScroll)
window.addEventListener('pagehide', () => saveScroll.flush()) // уходя — дописать последнее значение
Подключение к странице: debounce для ввода, throttle для прокрутки.
  • flush() по Enter — пользователь явно попросил искать, ждать паузу незачем.
  • Debounce не отменяет уже отправленный запрос: медленный ответ на «ма» может прийти позже ответа на «маникюр». Поэтому предыдущий запрос отменяется через AbortController — подробнее в уроке об асинхронном JavaScript.
  • Опция passive для scroll не нужна: по MDN это событие нельзя отменить, поэтому обработчик и так не задерживает прокрутку. Она важна для wheel и touchmove.
  • По pagehide вызываем flush(): иначе последняя позиция, ожидающая конца окна, потеряется при уходе со страницы.
  • В фоновых вкладках браузеры ограничивают минимальную задержку таймеров (по MDN, в Firefox для настольных систем — не меньше секунды), так что интервалы там растягиваются.

В React: useDebouncedValue и очистка

В React нужны две гарантии: у функции стабильная ссылка, иначе таймер теряется между рендерами, и таймер снимается при размонтировании. useDebouncedValue откладывает само значение, а debounce для подписки на событие живёт внутри эффекта.

src/useDebounced.ts
import { useEffect, useState } from 'react'
import { debounce } from './debounce'

// Значение, которое обновляется только после паузы в изменениях
export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs)
    return () => clearTimeout(id) // новое значение отменяет предыдущий таймер
  }, [value, delayMs])

  return debounced
}

// Ширина окна, пересчитанная после того, как пользователь закончил менять размер
export function useWindowWidth(delayMs = 200): number {
  const [width, setWidth] = useState(() => window.innerWidth)

  useEffect(() => {
    // одна debounce-функция на всё время подписки
    const onResize = debounce(() => setWidth(window.innerWidth), delayMs)
    window.addEventListener('resize', onResize)
    return () => {
      window.removeEventListener('resize', onResize)
      onResize.cancel() // отложенный вызов не должен сработать после размонтирования
    }
  }, [delayMs])

  return width
}
Хуки: отложенное значение и ширина окна после ресайза.
src/ServiceSearch.tsx
import { useEffect, useState } from 'react'
import { useDebouncedValue } from './useDebounced'

type Props = {
  searchServices: (query: string, signal: AbortSignal) => Promise<string[]>
}

export function ServiceSearch({ searchServices }: Props) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<string[]>([])
  const debouncedQuery = useDebouncedValue(query.trim(), 300)

  useEffect(() => {
    if (debouncedQuery === '') return
    const controller = new AbortController()
    searchServices(debouncedQuery, controller.signal)
      .then(setResults)
      .catch((error: unknown) => {
        if (!controller.signal.aborted) console.error(error)
      })
    return () => controller.abort() // устаревший запрос отменяется
  }, [debouncedQuery, searchServices])

  return (
    <div role="search">
      <label htmlFor="service-query">Поиск услуги</label>
      <input
        id="service-query"
        type="search"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
      <ul>
        {(debouncedQuery === '' ? [] : results).map((name) => (
          <li key={name}>{name}</li>
        ))}
      </ul>
    </div>
  )
}
Поиск услуг: запрос уходит по отложенному значению, устаревший отменяется в очистке эффекта.
src/bad/BrokenSearch.tsx
import { useState } from 'react'
import { debounce } from '../debounce'

// Ошибка: debounce создаётся заново на каждом рендере
export function BrokenSearch({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState('')
  const debouncedSearch = debounce(onSearch, 300)

  return (
    <input
      aria-label="Поиск"
      value={query}
      onChange={(event) => {
        setQuery(event.target.value)
        debouncedSearch(event.target.value)
      }}
    />
  )
}
Так делать не надо: обёртка пересоздаётся на каждом рендере.

Если тормозит сама отрисовка, а не сеть, сначала попробуйте useDeferredValue: react.dev объясняет, что он не требует фиксированной задержки и подстраивается под устройство, а отложенный рендер прерывается новым вводом. Debounce и throttle остаются полезны для работы вне рендера — например, чтобы отправлять меньше запросов.

Если результаты поиска объявляются скринридеру через live-регион (урок «Доступность в коде»), отложенное обновление заодно избавляет пользователя от объявления на каждое нажатие клавиши.

Как проверить: тесты на поддельных таймерах

Ждать настоящие 300 мс в тестах не нужно: vi.useFakeTimers() подменяет setTimeout, clearTimeout и Date, а vi.advanceTimersByTime(ms) проматывает время вперёд. vi.getTimerCount() показывает, сколько таймеров осталось, — так проверяется, что cancel() и очистка эффекта ничего не забыли.

bash
mkdir rate-limit && cd rate-limit
npm init -y
npm pkg set type=module
npm install -D typescript vitest jsdom react react-dom @types/react @types/react-dom @testing-library/react @testing-library/dom
Временный проект: TypeScript, Vitest, jsdom для DOM и React-тестов.
tsconfig.json
{
  "compilerOptions": {
    "target": "es2023",
    "lib": ["ES2023", "DOM"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src"]
}
Строгий режим TypeScript; noEmit — проверка типов без сборки.
src/debounce.test.ts
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { debounce } from './debounce'

beforeEach(() => {
  vi.useFakeTimers()
})

afterEach(() => {
  vi.useRealTimers()
})

test('trailing: один вызов через паузу после последнего, с последними аргументами', () => {
  const fn = vi.fn()
  const search = debounce(fn, 300)

  search('м')
  vi.advanceTimersByTime(200)
  search('ма') // таймер начинается заново
  vi.advanceTimersByTime(200)
  search('маникюр')
  vi.advanceTimersByTime(299)
  expect(fn).not.toHaveBeenCalled()

  vi.advanceTimersByTime(1)
  expect(fn).toHaveBeenCalledTimes(1)
  expect(fn).toHaveBeenCalledWith('маникюр')
})

test('leading: первый вызов сразу, одиночный вызов не повторяется', () => {
  const fn = vi.fn()
  const save = debounce(fn, 300, { leading: true, trailing: false })

  save(1)
  save(2)
  save(3)
  expect(fn.mock.calls).toEqual([[1]])

  vi.advanceTimersByTime(300)
  save(4) // пауза прошла — новая серия
  expect(fn.mock.calls).toEqual([[1], [4]])
})

test('leading + trailing: края серии, без двойного вызова подряд', () => {
  const fn = vi.fn()
  const d = debounce(fn, 300, { leading: true, trailing: true })

  d('a')
  expect(fn.mock.calls).toEqual([['a']])
  vi.advanceTimersByTime(300)
  expect(fn).toHaveBeenCalledTimes(1) // одиночный вызов не дублируется в конце

  d('b')
  d('c')
  vi.advanceTimersByTime(300)
  expect(fn.mock.calls).toEqual([['a'], ['b'], ['c']])

  d('d') // сразу после trailing — ждёт новую паузу, а не срабатывает мгновенно
  expect(fn).toHaveBeenCalledTimes(3)
  vi.advanceTimersByTime(300)
  expect(fn.mock.calls.at(-1)).toEqual(['d'])
})

test('cancel отменяет отложенный вызов и снимает таймер', () => {
  const fn = vi.fn()
  const d = debounce(fn, 300)

  d('x')
  expect(d.pending()).toBe(true)
  d.cancel()
  expect(d.pending()).toBe(false)
  expect(vi.getTimerCount()).toBe(0)
  vi.advanceTimersByTime(1000)
  expect(fn).not.toHaveBeenCalled()
})

test('flush выполняет отложенный вызов сразу и только один раз', () => {
  const fn = vi.fn()
  const d = debounce(fn, 300)

  d('x')
  d.flush()
  expect(fn.mock.calls).toEqual([['x']])
  vi.advanceTimersByTime(1000)
  expect(fn).toHaveBeenCalledTimes(1)

  d.flush() // нечего выполнять
  expect(fn).toHaveBeenCalledTimes(1)
})
Debounce: trailing, leading, оба края, cancel, flush.
src/throttle.test.ts
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { throttle } from './throttle'

beforeEach(() => {
  vi.useFakeTimers()
})

afterEach(() => {
  vi.useRealTimers()
})

// 25 событий раз в 10 мс — как поток scroll в течение четверти секунды
function stream(handler: (i: number) => void) {
  for (let i = 0; i < 25; i++) {
    handler(i)
    vi.advanceTimersByTime(10)
  }
}

test('по умолчанию: сразу, затем не чаще раза в 100 мс, последний вызов не теряется', () => {
  const calls: [number, number][] = []
  const onScroll = throttle((i: number) => calls.push([Date.now(), i]), 100)
  const start = Date.now()

  stream(onScroll)
  vi.advanceTimersByTime(500)

  expect(calls.map(([, i]) => i)).toEqual([0, 9, 19, 24])
  expect(calls.map(([t]) => t - start)).toEqual([0, 100, 200, 300])
})

test('trailing: false — только вызовы в начале окон', () => {
  const fn = vi.fn()
  stream(throttle(fn, 100, { trailing: false }))
  vi.advanceTimersByTime(500)
  expect(fn.mock.calls).toEqual([[0], [10], [20]])
})

test('leading: false — первый вызов в конце окна, с последними аргументами', () => {
  const fn = vi.fn()
  const t = throttle(fn, 100, { leading: false })

  t(1)
  t(2)
  expect(fn).not.toHaveBeenCalled()
  vi.advanceTimersByTime(100)
  expect(fn.mock.calls).toEqual([[2]])
})

test('cancel и flush', () => {
  const fn = vi.fn()
  const t = throttle(fn, 100)

  t(1) // сразу
  t(2) // ждёт конца окна
  t.cancel()
  vi.advanceTimersByTime(500)
  expect(fn.mock.calls).toEqual([[1]])
  expect(vi.getTimerCount()).toBe(0)

  t(3)
  t(4)
  t.flush() // выполнить отложенный вызов немедленно
  expect(fn.mock.calls).toEqual([[1], [3], [4]])
  expect(t.pending()).toBe(false)
})
Throttle: частота и моменты вызовов, без trailing, без leading, cancel и flush.
src/useDebounced.test.tsx
// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { useDebouncedValue, useWindowWidth } from './useDebounced'

beforeEach(() => {
  vi.useFakeTimers()
})

afterEach(() => {
  cleanup()
  vi.useRealTimers()
})

test('useDebouncedValue отдаёт значение через паузу после последнего изменения', () => {
  const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 300), {
    initialProps: { value: '' },
  })

  rerender({ value: 'м' })
  act(() => vi.advanceTimersByTime(200))
  rerender({ value: 'ма' })
  act(() => vi.advanceTimersByTime(299))
  expect(result.current).toBe('')

  act(() => vi.advanceTimersByTime(1))
  expect(result.current).toBe('ма')
})

test('useDebouncedValue: размонтирование снимает таймер', () => {
  const { rerender, unmount } = renderHook(({ value }) => useDebouncedValue(value, 300), {
    initialProps: { value: 'a' },
  })
  rerender({ value: 'b' })
  expect(vi.getTimerCount()).toBe(1)
  unmount()
  expect(vi.getTimerCount()).toBe(0)
})

function resizeTo(width: number) {
  Object.defineProperty(window, 'innerWidth', { value: width, configurable: true })
  window.dispatchEvent(new Event('resize'))
}

test('useWindowWidth: один пересчёт после серии resize, отмена при размонтировании', () => {
  const { result, unmount } = renderHook(() => useWindowWidth(200))
  const initial = result.current

  for (const width of [900, 800, 700]) {
    resizeTo(width)
    act(() => vi.advanceTimersByTime(50))
  }
  expect(result.current).toBe(initial)
  act(() => vi.advanceTimersByTime(150))
  expect(result.current).toBe(700)

  resizeTo(600)
  unmount()
  expect(vi.getTimerCount()).toBe(0)
})
Хуки: renderHook, act и поддельные таймеры; размонтирование снимает таймер.
terminal
$ npx tsc --noEmit -p .
$ npx vitest run

 Test Files  5 passed (5)
      Tests  18 passed (18)
Вывод сокращён. Пять файлов тестов: к показанным добавлены catalog.test.ts и ServiceSearch.test.tsx (в jsdom, с подменой fetch).

Чтобы убедиться, что тесты ловят ошибки, мы по очереди ломали код: убирали clearTimeout в debounce, паузу после trailing-вызова, новое окно в throttle, cancel() в хуке, очистку в useDebouncedValue, flush() по Enter и abort() старого запроса. Каждый раз падал хотя бы один тест.

Когда хватит requestAnimationFrame и другие ограничения

  • Для scroll обёртка на requestAnimationFrame частоту не снизит: MDN прямо пишет, что колбэки кадра вызываются с той же частотой, что и обработчики прокрутки, и советует отмерять интервал через setTimeout, как в нашем throttle.
  • Если нужно реагировать на пересечение порога — показать кнопку «наверх», подгрузить следующую порцию, — MDN советует IntersectionObserver: там не нужен ни throttle, ни обработчик прокрутки.
  • Обёртки не сохраняют this и отбрасывают возвращаемое значение: результат debounced-функции появляется позже, поэтому возвращать его некуда. Для методов класса передавайте стрелочную функцию.
  • Задержка — компромисс: 300 мс для поиска и 100–250 мс для прокрутки — наш выбор для примеров, а не стандарт. Подбирайте её по ощущениям пользователей и нагрузке на сервер.
  • Проверка шла в Node.js и jsdom на поддельных таймерах; в настоящем браузере код рецепта мы не запускали.

Основы рецепта: события input и scroll, addEventListener — в уроке «JavaScript в браузере: DOM и события»; эффекты, очистка и AbortController в React — в «useEffect: синхронизация с внешним миром и когда он не нужен»; live-регионы и объявления для скринридера — в «Доступность в коде: семантика, фокус, ARIA».

Как проверено5 · пройденосверено 12 октября 2026 г.
  • запущено, работает

    src/debounce.ts, src/throttle.ts

    tsc --noEmit (strict) на TypeScript 6.0.3 и 7.0.2 — 0 ошибок; vitest 5.0.3 в Node.js 24.18.0 с поддельными таймерами: debounce 5 тестов, throttle 4 теста; таблица «25 событий через 10 мс» получена пробным тестом (debounce: 340 мс/№24; leading: 0 и 340; throttle: 0/100/200/300, №0/9/19/24)

  • запущено, работает

    src/catalog.ts

    vitest в jsdom 30.1.2 с подменой fetch: один запрос после паузы, flush по Enter, сообщение при HTTP 500, медленный старый ответ не перезаписывает свежий (AbortController), sessionStorage не чаще раза в 250 мс + запись по pagehide

  • запущено, работает

    src/useDebounced.ts, src/ServiceSearch.tsx, src/bad/BrokenSearch.tsx

    vitest + @testing-library/react 16.3.3, React 19.3.0, jsdom: useDebouncedValue (пауза, снятие таймера при размонтировании), useWindowWidth (один пересчёт, cancel при размонтировании), ServiceSearch — один запрос на 4 нажатия; BrokenSearch — 4 вызова вместо одного. ESLint 10.12.0 + eslint-plugin-react-hooks 7.1.1 (flat recommended) — 0 замечаний, включая BrokenSearch. Ранний вариант useDebouncedCallback с useRef+useMemo отклонён: правило react-hooks/refs

  • запущено, работает

    Мутационная проверка тестов

    по одной поломке: без clearTimeout в debounce, без паузы после trailing при leading, leading на каждом вызове, без нового окна в throttle, без проверки окна, без cancel в useWindowWidth, без очистки в useDebouncedValue, без flush по Enter, без abort старого запроса — каждый раз падает ≥1 тест

  • запущено, работает

    EN-версия кода

    копия rate-limit-en с переведёнными строками и комментариями: vitest 18/18, tsc 6.0.3 и 7.0.2, eslint — чисто

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.