Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new hook useDebounceCallback #40

Merged
merged 3 commits into from
May 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ npm i @react-hookz/web
yarn add @react-hookz/web
```

As hooks was introduced to the world in React 16.8, `@react-hookz/web` requires - you gessed it

- `react` and `react-dom` 16.8+.
Also, as React does not support IE, `@react-hookz/web` does not do so either. You'll have to
transpile your `node-modules` in order to run in IE.
As hooks was introduced to the world in React 16.8, `@react-hookz/web` requires - you guessed it -
`react` and `react-dom` 16.8+.
Also, as React does not support IE, `@react-hookz/web` does not do so either. You'll have to
transpile your `node-modules` in order to run in IE.

## Usage

Expand All @@ -50,6 +49,11 @@ import { useMountEffect } from "@react-hookz/web/esnext";

## Hooks list

- #### Callback

- [`useDebounceCallback`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usedebouncecallback--example)
— Makes passed function debounced, otherwise acts like `useCallback`.

- #### Lifecycle

- [`useConditionalEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionaleffect--example)
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export { useConditionalEffect } from './useConditionalEffect';
export { useConditionalUpdateEffect } from './useConditionalUpdateEffect';
export { useSafeState } from './useSafeState';
export { useMediatedState } from './useMediatedState';
export { useDebounceCallback } from './useDebounceCallback';
export { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
30 changes: 30 additions & 0 deletions src/useDebounceCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { DependencyList, useMemo, useRef } from 'react';

/**
* Makes passed function debounced, otherwise acts like `useCallback`.
*
* @param cb Function that will be debounced.
* @param delay Debounce delay.
* @param deps Dependencies list when to update callback.
*/
export function useDebounceCallback<T extends unknown[]>(
cb: (...args: T) => unknown,
delay: number,
deps: DependencyList
): (...args: T) => void {
const timeout = useRef<ReturnType<typeof setTimeout>>();

return useMemo(
() => (...args: T): void => {
if (timeout.current) clearTimeout(timeout.current);

timeout.current = setTimeout(() => {
timeout.current = undefined;

cb(...args);
}, delay);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[delay, ...deps]
);
}
23 changes: 23 additions & 0 deletions stories/Callback/useDebounceCallback.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { useState } from 'react';
import { useDebounceCallback } from '../../src';

export const Example: React.FC = () => {
const [state, setState] = useState('');

const handleChange: React.ChangeEventHandler<HTMLInputElement> = useDebounceCallback(
(ev) => {
setState(ev.target.value);
},
500,
[]
);

return (
<div>
<div>Below state will update 500ms after last change</div>
<br />
<div>The input`s value is: {state}</div>
<input type="text" onChange={handleChange} />
</div>
);
};
34 changes: 34 additions & 0 deletions stories/Callback/useDebounceCallback.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {Canvas, Meta, Story} from "@storybook/addon-docs/blocks";
import {Example} from "./useDebounceCallback.stories";

<Meta title="Callback/useDebounceCallback" component={Example} />

# useDebounceCallback

The third argument is a list of dependencies, as for `useCallback`.

> The third argument is dependencies list on `useEffect` manner, passed function will be re-wrapped
when delay or dependencies has changed. Changed debounce callbacks still has same timeout, meaning
that calling new debounced function will abort previously scheduled invocation.

> Debounced function is always a void function since original callback invoked later.

#### Example

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
function useDebounceCallback<T extends unknown[]>(
cb: (...args: T) => unknown,
delay: number,
deps: React.DependencyList
): (...args: T) => void
```

- **cb** _`(...args: T) => unknown`_ - function that will be debounced.
- **delay** _`number`_ - debounce delay.
- **deps** _`React.DependencyList`_ - dependencies list when to update callback.
83 changes: 83 additions & 0 deletions tests/dom/useDebounceCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { useDebounceCallback } from '../../src';

describe('useDebounceCallback', () => {
beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

it('should be defined', () => {
expect(useDebounceCallback).toBeDefined();
});

it('should render', () => {
renderHook(() => {
useDebounceCallback(() => {}, 200, []);
});
});

it('should return new callback if delay is changed', () => {
const { result, rerender } = renderHook(
({ delay }) => useDebounceCallback(() => {}, delay, []),
{
initialProps: { delay: 200 },
}
);

const cb1 = result.current;
rerender({ delay: 123 });

expect(cb1).not.toBe(result.current);
});

it('should run given callback only after specified delay since last call', () => {
const cb = jest.fn();
const { result } = renderHook(() => useDebounceCallback(cb, 200, []));

result.current();
expect(cb).not.toHaveBeenCalled();

jest.advanceTimersByTime(100);
result.current();

jest.advanceTimersByTime(199);
expect(cb).not.toHaveBeenCalled();

jest.advanceTimersByTime(1);
expect(cb).toHaveBeenCalledTimes(1);
});

it('should pass parameters to callback', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const cb = jest.fn((_a: number, _c: string) => {});
const { result } = renderHook(() => useDebounceCallback(cb, 200, []));

result.current(1, 'abc');
jest.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledWith(1, 'abc');
});

it('should cancel previously scheduled call even if parameters changed', () => {
const cb1 = jest.fn(() => {});
const cb2 = jest.fn(() => {});

const { result, rerender } = renderHook(
({ i }) => useDebounceCallback(() => (i === 1 ? cb1() : cb2()), 200, [i]),
{ initialProps: { i: 1 } }
);

result.current();
jest.advanceTimersByTime(100);

rerender({ i: 2 });
result.current();
jest.advanceTimersByTime(200);

expect(cb1).not.toHaveBeenCalled();
expect(cb2).toHaveBeenCalledTimes(1);
});
});
49 changes: 49 additions & 0 deletions tests/ssr/useDebounceCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useDebounceCallback } from '../../src';

describe('useDebounceCallback', () => {
beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

it('should be defined', () => {
expect(useDebounceCallback).toBeDefined();
});

it('should render', () => {
renderHook(() => {
useDebounceCallback(() => {}, 200, []);
});
});

it('should run given callback only after specified delay since last call', () => {
const cb = jest.fn();
const { result } = renderHook(() => useDebounceCallback(cb, 200, []));

result.current();
expect(cb).not.toHaveBeenCalled();

jest.advanceTimersByTime(100);
result.current();

jest.advanceTimersByTime(199);
expect(cb).not.toHaveBeenCalled();

jest.advanceTimersByTime(1);
expect(cb).toHaveBeenCalledTimes(1);
});

it('should pass parameters to callback', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const cb = jest.fn((_a: number, _c: string) => {});
const { result } = renderHook(() => useDebounceCallback(cb, 200, []));

result.current(1, 'abc');
jest.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledWith(1, 'abc');
});
});