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 useThrottleCallback #118

Merged
merged 1 commit into from
Jun 15, 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";
— Makes passed function debounced, otherwise acts like `useCallback`.
- [**`useRafCallback`**](https://react-hookz.github.io/web/?path=/docs/callback-userafcallback)
— Makes passed function to be called within next animation frame.
- [**`useThrottleCallback`**](https://react-hookz.github.io/web/?path=/docs/callback-usethrottlecallback)
— Makes passed function throttled, otherwise acts like `useCallback`.

- #### Lifecycle

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Callback
export { useDebounceCallback } from './useDebounceCallback/useDebounceCallback';
export { useRafCallback } from './useRafCallback/useRafCallback';
export { useThrottleCallback } from './useThrottleCallback/useThrottleCallback';

// Livecycle
export {
Expand Down
1 change: 0 additions & 1 deletion src/useCookieValue/useCookieValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { Dispatch, useCallback, useEffect } from 'react';
import * as Cookies from 'js-cookie';
import { useSafeState, useSyncedRef, useFirstMountState, useMountEffect } from '..';

import { isBrowser } from '../util/const';

const cookiesSetters = new Map<string, Set<Dispatch<string | null>>>();
Expand Down
6 changes: 1 addition & 5 deletions src/useStorageValue/useStorageValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,10 @@ import {
useSyncedRef,
useFirstMountState,
usePrevious,
useMountEffect,
} from '..';

import { INextState, resolveHookState } from '../util/resolveHookState';

import { useMountEffect } from '../useMountEffect/useMountEffect';

import { isBrowser } from '../util/const';

import { off, on } from '../util/misc';

export type IUseStorageValueOptions<
Expand Down
23 changes: 23 additions & 0 deletions src/useThrottleCallback/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { useState } from 'react';
import { useThrottleCallback } from '../..';

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

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

return (
<div>
<div>Below state will update no more than once every 500ms</div>
<br />
<div>The input`s value is: {state}</div>
<input type="text" onChange={handleChange} />
</div>
);
};
37 changes: 37 additions & 0 deletions src/useThrottleCallback/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

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

# useThrottleCallback

Makes passed function throttled, otherwise acts like `useCallback`.
[\[What is throttling?\]](https://css-tricks.com/debouncing-throttling-explained-examples/#throttle)

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

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

#### Example

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

## Reference

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

- **cb** _`(...args: T) => unknown`_ - function that will be throttled.
- **delay** _`number`_ - throttle delay.
- **deps** _`React.DependencyList`_ - dependencies list when to update callback.
- **noTrailing** _`boolean`_ _(default: false)_ - if noTrailing is true, callback will only execute
every `delay` milliseconds, otherwise, callback will be executed once, after the last call.
112 changes: 112 additions & 0 deletions src/useThrottleCallback/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { useThrottleCallback } from '../..';

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

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

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

it('should render', () => {
const { result } = renderHook(() => {
useThrottleCallback(() => {}, 200, []);
});
expect(result.error).toBeUndefined();
});

it('should return function same length and wrapped name', () => {
let { result } = renderHook(() =>
useThrottleCallback((_a: any, _b: any, _c: any) => {}, 200, [])
);

expect(result.current.length).toBe(3);
expect(result.current.name).toBe(`anonymous__throttled__200`);

function testFn(_a: any, _b: any, _c: any) {}

result = renderHook(() => useThrottleCallback(testFn, 100, [])).result;

expect(result.current.length).toBe(3);
expect(result.current.name).toBe(`testFn__throttled__100`);
});

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

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

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

it('should invoke given callback immediately', () => {
const cb = jest.fn();
const { result } = renderHook(() => useThrottleCallback(cb, 200, []));

result.current();
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(() => useThrottleCallback(cb, 200, []));

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

it('should ignore consequential calls occurred within delay, but execute last call after delay is passed', () => {
const cb = jest.fn();
const { result } = renderHook(() => useThrottleCallback(cb, 200, []));

result.current();
result.current();
result.current();
result.current();
expect(cb).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(199);
result.current();
expect(cb).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(1);
expect(cb).toHaveBeenCalledTimes(2);
result.current();
expect(cb).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledTimes(3);
});

it('should drop trailing execution if `noTrailing is set to true`', () => {
const cb = jest.fn();
const { result } = renderHook(() => useThrottleCallback(cb, 200, [], true));

result.current();
result.current();
result.current();
result.current();
expect(cb).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(199);
result.current();
expect(cb).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(1);
expect(cb).toHaveBeenCalledTimes(1);
result.current();
result.current();
result.current();
expect(cb).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledTimes(2);
});
});
41 changes: 41 additions & 0 deletions src/useThrottleCallback/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useThrottleCallback } from '../..';

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

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

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

it('should render', () => {
const { result } = renderHook(() => {
useThrottleCallback(() => {}, 200, []);
});
expect(result.error).toBeUndefined();
});

it('should invoke given callback immediately', () => {
const cb = jest.fn();
const { result } = renderHook(() => useThrottleCallback(cb, 200, []));

result.current();
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(() => useThrottleCallback(cb, 200, []));

result.current(1, 'abc');
jest.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledWith(1, 'abc');
});
});
73 changes: 73 additions & 0 deletions src/useThrottleCallback/useThrottleCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DependencyList, useMemo, useRef } from 'react';
import { useUnmountEffect } from '..';

export interface IThrottledFunction<Args extends any[], This> {
(this: This, ...args: Args): void;
}

/**
* Makes passed function throttled, otherwise acts like `useCallback`.
*
* @param callback Function that will be throttled.
* @param delay Throttle delay.
* @param deps Dependencies list when to update callback.
* @param noTrailing If noTrailing is true, callback will only execute every
* `delay` milliseconds, otherwise, callback will be executed one final time
* after the last throttled-function call.
*/
export function useThrottleCallback<Args extends any[], This>(
callback: (this: This, ...args: Args) => any,
delay: number,
deps: DependencyList,
noTrailing = false
): IThrottledFunction<Args, This> {
const timeout = useRef<ReturnType<typeof setTimeout>>();
const lastCall = useRef<{ args: Args; this: This }>();

useUnmountEffect(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
});

return useMemo(() => {
const execute = (context: This, args: Args) => {
lastCall.current = undefined;
callback.apply(context, args);

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

// if trailing execution is not disabled - call callback with last
// received arguments and context
if (!noTrailing && lastCall.current) {
execute(lastCall.current.this, lastCall.current.args);

lastCall.current = undefined;
}
}, delay);
};

// eslint-disable-next-line func-names
const wrapped = function (this, ...args) {
if (timeout.current) {
// if we cant execute callback immediately - save its arguments and
// context to execute it when delay is passed
lastCall.current = { args, this: this };

return;
}

execute(this, args);
} as IThrottledFunction<Args, This>;

Object.defineProperties(wrapped, {
length: { value: callback.length },
name: { value: `${callback.name || 'anonymous'}__throttled__${delay}` },
});

return wrapped;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [delay, noTrailing, ...deps]);
}