Skip to content

Commit

Permalink
feat!: rework debounce function
Browse files Browse the repository at this point in the history
Co-Authored-By: Adam C Hamlin <[email protected]>
  • Loading branch information
aleclarson and adamhamlin committed Jan 26, 2025
1 parent 48f29b9 commit b18ca32
Show file tree
Hide file tree
Showing 3 changed files with 108 additions and 120 deletions.
92 changes: 43 additions & 49 deletions docs/curry/debounce.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,33 @@ since: 12.1.0

### Usage

The `debounce` function helps manage frequent function calls efficiently. It requires two inputs: a `delay` time (in milliseconds) and a callback. When you use the function returned by `debounce` (a.k.a. the “debounced function”), it doesn't immediately run your callback. Instead, it waits for the specified `delay`.

If called again during this waiting period, it resets the timer. Your source function only runs after the full `delay` passes without interruption. This is useful for handling rapid events like keystrokes, ensuring your code responds only after a pause in activity.
Create a new function that delays invoking a callback until after a specified time has elapsed since the last call.

```ts
import * as _ from 'radashi'

const processData = (data: string) => {
console.log(`Processing data: "${data}"...`)
}
const debouncedProcessData = _.debounce({ delay: 100 }, processData)
// Debounce a search function to avoid making API calls on every keystroke
const searchAPI = _.debounce({ delay: 300 }, async query => {
try {
const response = await fetch(`https://api.example.com/search?q=${query}`)
const results = await response.json()
displayResults(results)
} catch (error) {
console.error('Search failed:', error)
}
})

debouncedProcessData('data1') // Never logs
debouncedProcessData('data2') // Never logs
debouncedProcessData('data3') // Processing data: "data3"...
// Simulate user typing in a search box
document.querySelector('#search-input').addEventListener('input', event => {
const query = event.target.value
searchAPI(query)
})

setTimeout(() => {
debouncedProcessData('data4') // Processing data: "data4"... (200ms later)
}, 200)
function displayResults(results) {
// Update UI with search results
console.log('Search results:', results)
}
const debouncedProcessData = _.debounce({ delay: 100 }, processData)
```

## Options
Expand All @@ -34,60 +42,46 @@ setTimeout(() => {
When the `leading` option is `true`, your callback is invoked immediately the very first time the debounced function is called. After that, the debounced function works as if `leading` was `false`.

```ts
const myDebouncedFunc = _.debounce({ delay: 100, leading: true }, x => {
const debouncedFunc = _.debounce({ delay: 100, leading: true }, x => {
console.log(x)
})

myDebouncedFunc(0) // Logs "0" immediately
myDebouncedFunc(1) // Never logs
myDebouncedFunc(2) // Logs "2" about 100ms later
debouncedFunc(0) // Logs "0" immediately
debouncedFunc(1) // Never logs
debouncedFunc(2) // Logs "2" about 100ms later
```

## Methods
## Return type

### cancel
The `DebounceFunction` type is used to represent a debounced function in TypeScript. It has the following properties:

The `cancel` method of the debounced function does two things:
- `callee`: The underlying function that is debounced.
- `flush`: A method that forces a debounced call to execute immediately.
- `cancel`: A method that cancels a debounced function.

1. It cancels any pending invocations of the debounced function.
2. It permanently disables the debouncing behavior. All future invocations of the debounced function will immediately invoke your callback.
### Flushing

```ts
const myDebouncedFunc = _.debounce({ delay: 100 }, x => {
console.log(x)
})

myDebouncedFunc(0) // Never logs
myDebouncedFunc(1) // Never logs
myDebouncedFunc.cancel()
myDebouncedFunc(2) // Logs "2" immediately
```

### flush

The `flush` method will immediately invoke your callback, regardless of whether the debounced function is currently pending.
The `flush` method forces a debounced call to execute immediately. If no call was currently scheduled, this method does nothing. So you're effectively saying “execute the function now, but only if it's scheduled to execute.”

```ts
const myDebouncedFunc = _.debounce({ delay: 100 }, x => {
console.log(x)
const debouncedFunc = _.debounce({ delay: 1000 }, () => {
console.log('Flushed')
})

myDebouncedFunc(0) // Logs "0" about 100ms later
myDebouncedFunc.flush(1) // Logs "1" immediately
debouncedFunc.flush()
```

### isPending
The `flush` method is set to [`noop`](/reference/function/noop) when no pending call is scheduled.

The `isPending` method returns `true` if there is any pending invocation of the debounced function.
### Cancelling

The `cancel` method can be used to cancel a debounced function. Future calls still get debounced, but the currently pending call is immediately cancelled.

```ts
const myDebouncedFunc = _.debounce({ delay: 100 }, x => {
console.log(x)
const debouncedFunc = _.debounce({ delay: 1000 }, () => {
console.log('Flushed')
})

myDebouncedFunc(0) // Logs "0" about 100ms later
myDebouncedFunc.isPending() // => true
setTimeout(() => {
myDebouncedFunc.isPending() // => false
}, 100)
debouncedFunc()
debouncedFunc.cancel()
```
65 changes: 34 additions & 31 deletions src/curry/debounce.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { noop } from 'radashi'

declare const setTimeout: (fn: () => void, ms: number) => unknown
declare const clearTimeout: (timer: unknown) => void

export type DebounceFunction<TArgs extends any[]> = {
export interface DebounceFunction<TArgs extends any[] = any> {
(...args: TArgs): void
/**
* When called, future invocations of the debounced function are
* no longer delayed and are instead executed immediately.
*/
cancel(): void
/**
* Returns `true` if the underlying function is scheduled to be
* called once the delay has passed.
* If the debounced function is pending, it will be invoked
* immediately and the result will be returned. Otherwise,
* `undefined` will be returned.
*/
isPending(): boolean
flush(...args: TArgs): void
/**
* Invoke the underlying function immediately.
* The underlying function
*/
flush(...args: TArgs): void
readonly callee: (...args: TArgs) => unknown
}

export interface DebounceOptions {
Expand All @@ -32,11 +35,11 @@ export interface DebounceOptions {
}

/**
* Returns a new function that will only call your callback after
* `delay` milliseconds have passed without any invocations.
* Returns a new function that will only call the source function
* after `delay` milliseconds have passed without any invocations.
*
* The debounced function has a few methods, such as `cancel`,
* `isPending`, and `flush`.
* See the documentation (or the `DebounceFunction` type) for details
* on the methods and properties available on the returned function.
*
* @see https://radashi.js.org/reference/curry/debounce
* @example
Expand All @@ -53,33 +56,33 @@ export interface DebounceOptions {
*/
export function debounce<TArgs extends any[]>(
{ delay, leading }: DebounceOptions,
func: (...args: TArgs) => any,
callee: (...args: TArgs) => unknown,
): DebounceFunction<TArgs> {
let timer: unknown = undefined
let active = true
let timeout: unknown

const debounced: DebounceFunction<TArgs> = (...args: TArgs) => {
if (active) {
clearTimeout(timer)
timer = setTimeout(() => {
active && func(...args)
timer = undefined
}, delay)
if (leading) {
func(...args)
leading = false
}
const debounced = ((...args: TArgs) => {
clearTimeout(timeout)
if (leading) {
leading = false
callee(...args)
} else {
func(...args)
timeout = setTimeout(
(debounced.flush = () => {
debounced.flush = noop
clearTimeout(timeout)
callee(...args)
}),
delay,
)
}
}
debounced.isPending = () => {
return timer !== undefined
}
}) as DebounceFunction<TArgs> & { callee: typeof callee }

debounced.callee = callee
debounced.flush = noop
debounced.cancel = () => {
active = false
debounced.flush = noop
clearTimeout(timeout)
}
debounced.flush = (...args: TArgs) => func(...args)

return debounced
}
71 changes: 31 additions & 40 deletions tests/curry/debounce.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as _ from 'radashi'
import type { DebounceFunction } from 'radashi'
import * as _ from 'radashi'

describe('debounce', () => {
let func: DebounceFunction<any>
Expand All @@ -20,60 +20,51 @@ describe('debounce', () => {
vi.clearAllMocks()
})

test('only executes once when called rapidly', async () => {
test('only executes once when called rapidly', () => {
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(1)
})

test('does not debounce after cancel is called', () => {
test('cancel prevents the debounced function from being called', () => {
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)
func.cancel()
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(3)
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(6)
})

test('executes the function immediately when the flush method is called', () => {
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)
})
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(0)

test('continues to debounce after flush is called', async () => {
// Verify that new calls after cancel are debounced normally
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)
func()
expect(mockFunc).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(2)
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(3)
expect(mockFunc).toHaveBeenCalledTimes(1)
})

test('cancels all pending invocations when the cancel method is called', async () => {
const results: boolean[] = []
func()
results.push(func.isPending())
results.push(func.isPending())
vi.advanceTimersByTime(delay + 10)
results.push(func.isPending())
func()
results.push(func.isPending())
vi.advanceTimersByTime(delay + 10)
results.push(func.isPending())
assert.deepEqual(results, [true, true, false, true, false])
})
describe('flush', () => {
test('only calls the function if the debounced function was called', () => {
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)

test('returns if there is any pending invocation when the pending method is called', async () => {
func()
func.cancel()
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(0)
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)
expect(func.isPending()).toBe(false)

Check failure on line 50 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (18.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:50:19

Check failure on line 50 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (18.x)

tests/curry/debounce.test.ts > debounce > flush > only calls the function if the debounced function was called

TypeError: func.isPending is not a function ❯ tests/curry/debounce.test.ts:50:19

Check failure on line 50 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (20.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:50:19

Check failure on line 50 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (20.x)

tests/curry/debounce.test.ts > debounce > flush > only calls the function if the debounced function was called

TypeError: func.isPending is not a function ❯ tests/curry/debounce.test.ts:50:19

Check failure on line 50 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:50:19

Check failure on line 50 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.x)

tests/curry/debounce.test.ts > debounce > flush > only calls the function if the debounced function was called

TypeError: func.isPending is not a function ❯ tests/curry/debounce.test.ts:50:19

func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)
expect(func.isPending()).toBe(false)

Check failure on line 54 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (18.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:54:19

Check failure on line 54 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (20.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:54:19

Check failure on line 54 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:54:19
})
test('debouncing resumes after a flush', () => {
runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(0)
func.flush()
expect(mockFunc).toHaveBeenCalledTimes(1)
expect(func.isPending()).toBe(false)

Check failure on line 61 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (18.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:61:19

Check failure on line 61 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (18.x)

tests/curry/debounce.test.ts > debounce > flush > debouncing resumes after a flush

TypeError: func.isPending is not a function ❯ tests/curry/debounce.test.ts:61:19

Check failure on line 61 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (20.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:61:19

Check failure on line 61 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (20.x)

tests/curry/debounce.test.ts > debounce > flush > debouncing resumes after a flush

TypeError: func.isPending is not a function ❯ tests/curry/debounce.test.ts:61:19

Check failure on line 61 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.x)

Unhandled error

TypeCheckError: Property 'isPending' does not exist on type 'DebounceFunction<any>'. ❯ tests/curry/debounce.test.ts:61:19

Check failure on line 61 in tests/curry/debounce.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.x)

tests/curry/debounce.test.ts > debounce > flush > debouncing resumes after a flush

TypeError: func.isPending is not a function ❯ tests/curry/debounce.test.ts:61:19

runFunc3Times()
expect(mockFunc).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(delay + 10)
expect(mockFunc).toHaveBeenCalledTimes(2)
})
})

test('executes the function immediately on the first invocation when `leading` is `true`', async () => {
Expand Down

0 comments on commit b18ca32

Please sign in to comment.