Decorator withDebounce
The withDebounce decorator is an optimization tool used in scenarios with a high event rate, where a strictly final result is important after the flow of actions has completely stopped or is quiet for a certain period of time.
Unlike throttling, debounce completely ignores intermediate states and resets the wait timer with each new user action. The request is sent only once when the "quiet" phase occurs.
Example
(wip)
ts
import { AbstractService } from '@pravosleva/reactive-engine'
// Наш декоратор дебаунса (wip)
interface DebounceOptions {
delay?: number
}
export const withDebounce = <S, T>(
fetcher: (source: S, signal: AbortSignal) => Promise<T>,
options: DebounceOptions = {}
) => {
const delay = options.delay ?? 300
let timeoutId: ReturnType<typeof setTimeout> | null = null
let rejectPrevious: ((reason: any) => void) | null = null
return (source: S, signal: AbortSignal): Promise<T> => {
if (timeoutId) clearTimeout(timeoutId)
if (rejectPrevious) {
rejectPrevious(new DOMException('Aborted due to debounce', 'AbortError'))
}
return new Promise<T>((resolve, reject) => {
rejectPrevious = reject
const onAbort = () => {
if (timeoutId) clearTimeout(timeoutId)
reject(new DOMException('Aborted by resource signal', 'AbortError'))
}
if (signal.aborted) return onAbort()
signal.addEventListener('abort', onAbort)
timeoutId = setTimeout(async () => {
signal.removeEventListener('abort', onAbort)
rejectPrevious = null
timeoutId = null
try {
const data = await fetcher(source, signal)
resolve(data)
} catch (error) {
reject(error)
}
}, delay)
})
}
}
// Сам бизнес-сервис
export class SearchLogic extends AbstractService {
// Сигнал, куда React-инпут будет записывать текст на каждый символ
public querySignal = this.createSignal<string>('', 'search:signal:query')
/**
* Реактивный ресурс, обёрнутый в декоратор withDebounce.
* Движок автоматически перезапускает его при изменении querySignal,
* но декоратор принудительно задерживает реальное выполнение на 500 мс.
*/
public searchResource = this.engine.resource(
withDebounce(
async (queryValue, abortSignal) => {
// Имитируем задержку ответа от сервера (например, чтение из базы)
await new Promise((resolve) => setTimeout(resolve, 400))
// Фейковый результат поиска
// В этом месте возвращается массив строк исключительно ради наглядности демонстрации в UI
// (чтобы в блоке результатов под инпутом можно было отрендерить список с помощью метода .map()).
return [
`Результат 1 для "${queryValue}"`,
`Результат 2 для "${queryValue}"`,
`Результат 3 для "${queryValue}"`
]
},
{ delay: 500 } // Задержка дебаунса 500 мс
),
this.querySignal,
{
name: 'search:resource:fetch',
// Не отправляем запрос, если инпут пустой
validateBeforeFetch: (queryValue) => !!queryValue.trim()
}
)
/**
* Экшен обновления поисковой строки из UI
*/
public updateQuery(val: string) {
this.querySignal.value = val
}
}tsx
import { ReactiveEngine, useReactiveValue } from '@pravosleva/reactive-engine'
import { SearchLogic } from './service.SearchLogic'
import { Input } from '~/shared/Input'
import baseClasses from '~/ui.common.module.scss'
import clsx from 'clsx'
const engine = new ReactiveEngine()
export const SearchExample = () => {
const logic = engine.inject(SearchLogic)
// Подписываемся на сигналы и ресурс
const query = engine.use(logic.querySignal)
const { loading, data: results, error } = useReactiveValue(logic.searchResource)
return (
<div
className={clsx(baseClasses.unit, baseClasses.stack2)}
style={{
fontFamily: 'system-ui',
width: 'max(100px, calc(100vw - 24px - 24px - 24px - 24px - 16px - 16px - 4px - 4px))'
}}
>
<div className={baseClasses.absoluteUnitLabel}>Simple Debounce Search Demo</div>
{/* Поле ввода текста */}
<div className={baseClasses.stack1} style={{ width: '100%', color: '#000' }}>
<label style={{ fontSize: 'small' }}>Живой поиск (дебаунс 500мс):</label>
<Input
variant='outlined'
type="text"
placeholder="Начните вводить текст..."
value={query}
onChange={(e) => logic.updateQuery(e.target.value)}
/>
</div>
{/* Статус-бар загрузки */}
<div className={baseClasses.stack1} style={{ fontSize: 'small' }}>
{
loading
? <span style={{ color: '#e6af2e' }}>⏳ Ждем окончания ввода и ответа сервера...</span>
: (query && !results)
? <span>Печатайте дальше...</span>
: <span>Печатайте дальше...</span>
}
{error && <span style={{ color: '#ef5350' }}>❌ Ошибка: {error.message}</span>}
</div>
{/* Отрендеренный список результатов */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', width: '100%' }}>
<div style={{ fontSize: 'small' }}>Результаты выдачи:</div>
<div style={{ background: '#111', borderRadius: '6px', padding: '12px', minHeight: '80px', display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px' }}>
{results && results.map((item, idx) => (
<div key={idx} style={{ color: '#4caf50' }}>{item}</div>
))}
{!query.trim() && <span style={{ color: '#aaa' }}>Строка поиска пуста</span>}
{query.trim() && !loading && !results && <span style={{ color: '#aaa' }}>Запрос задебаунсен...</span>}
</div>
</div>
</div>
)
}