Skip to content

Commit

Permalink
feat: new hooks useThrottledEffect and useThrottledState (#137)
Browse files Browse the repository at this point in the history
* feat: new hooks `useThrottledEffect` and `useThrottledState`

* Fix some typos

Co-authored-by: Joe Duncko <JoeDuncko@users.noreply.github.com>
  • Loading branch information
xobotyi and JoeDuncko committed Jun 17, 2021
1 parent b4f3833 commit 1cc6677
Show file tree
Hide file tree
Showing 15 changed files with 301 additions and 8 deletions.
4 changes: 4 additions & 0 deletions README.md
Expand Up @@ -85,6 +85,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";
— Run effect only when component first-mounted.
- [**`useRerender`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usererender)
— Return callback that re-renders component.
- [**`useThrottledEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usethrottledeffect)
— Like `useEffect`, but passed function is throttled.
- [**`useUnmountEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-useunmounteffect)
— Run effect only when component unmounted.
- [**`useUpdateEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-useupdateeffect)
Expand All @@ -102,6 +104,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";
— Like `useState`, but its state setter is guarded against sets on unmounted component.
- [**`useToggle`**](https://react-hookz.github.io/web/?path=/docs/state-usetoggle)
— Like `useState`, but can only become `true` or `false`.
- [**`useThrottledState`**](https://react-hookz.github.io/web/?path=/docs/state-usethrottledstate)
— Like `useSafeState` but its state setter is throttled.
- [**`useValidator`**](https://react-hookz.github.io/web/?path=/docs/state-usevalidator)
— Performs validation when any of provided dependencies has changed.

Expand Down
8 changes: 4 additions & 4 deletions src/index.ts
Expand Up @@ -12,19 +12,23 @@ export {
useConditionalUpdateEffect,
IUseConditionalUpdateEffectPredicate,
} from './useConditionalUpdateEffect/useConditionalUpdateEffect';
export { useDebouncedEffect } from './useDebouncedEffect/useDebouncedEffect';
export { useFirstMountState } from './useFirstMountState/useFirstMountState';
export { useIsMounted } from './useIsMounted/useIsMounted';
export { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect/useIsomorphicLayoutEffect';
export { useMountEffect } from './useMountEffect/useMountEffect';
export { useRerender } from './useRerender/useRerender';
export { useThrottledEffect } from './useThrottledEffect/useThrottledEffect';
export { useUnmountEffect } from './useUnmountEffect/useUnmountEffect';
export { useUpdateEffect } from './useUpdateEffect/useUpdateEffect';

// State
export { useDebouncedState } from './useDebouncedState/useDebouncedState';
export { useMediatedState } from './useMediatedState/useMediatedState';
export { usePrevious } from './usePrevious/usePrevious';
export { useSafeState } from './useSafeState/useSafeState';
export { useToggle } from './useToggle/useToggle';
export { useThrottledState } from './useThrottledState/useThrottledState';
export {
useValidator,
IValidatorImmediate,
Expand Down Expand Up @@ -68,7 +72,3 @@ export { useMediaQuery } from './useMediaQuery/useMediaQuery';

// Dom
export { useDocumentTitle, IUseDocumentTitleOptions } from './useDocumentTitle/useDocumentTitle';

export { useDebouncedEffect } from './useDebouncedEffect/useDebouncedEffect';

export { useDebouncedState } from './useDebouncedState/useDebouncedState';
2 changes: 1 addition & 1 deletion src/useDebouncedState/__docs__/example.stories.tsx
Expand Up @@ -6,7 +6,7 @@ export const Example: React.FC = () => {

return (
<div>
<div>Below state will update 200ms after last change, but at least once every 500ms</div>
<div>Below state will update 300ms after last change, but at least once every 500ms</div>
<br />
<div>The input`s value is: {state}</div>
<input
Expand Down
6 changes: 4 additions & 2 deletions src/useThrottledCallback/__docs__/story.mdx
Expand Up @@ -31,8 +31,10 @@ export function useThrottledCallback<Args extends any[], This>(
): IThrottledFunction<Args, This>;
```

- **cb** _`(...args: T) => unknown`_ - function that will be throttled.
#### Arguments

- **callback** _`(...args: T) => unknown`_ - function that will be throttled.
- **deps** _`React.DependencyList`_ - dependencies list when to update callback.
- **delay** _`number`_ - throttle delay.
- **noTrailing** _`boolean`_ _(default: false)_ - if noTrailing is true, callback will only execute
- **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.
2 changes: 1 addition & 1 deletion src/useThrottledCallback/useThrottledCallback.ts
Expand Up @@ -12,7 +12,7 @@ export interface IThrottledFunction<Args extends any[], This> {
* @param callback Function that will be throttled.
* @param deps Dependencies list when to update callback.
* @param delay Throttle delay.
* @param noTrailing If noTrailing is true, callback will only execute every
* @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.
*/
Expand Down
33 changes: 33 additions & 0 deletions src/useThrottledEffect/__docs__/example.stories.tsx
@@ -0,0 +1,33 @@
import * as React from 'react';
import { useState } from 'react';
import { useThrottledEffect } from '../..';

const HAS_DIGIT_REGEX = /[\d]/g;

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

useThrottledEffect(
() => {
setHasNumbers(HAS_DIGIT_REGEX.test(state));
},
[state],
200
);

return (
<div>
<div>Digit check will be performed no more than once every 200ms</div>
<br />
<div>{hasNumbers ? 'Input has digits' : 'No digits found in input'}</div>
<input
type="text"
value={state}
onChange={(ev) => {
setState(ev.target.value);
}}
/>
</div>
);
};
35 changes: 35 additions & 0 deletions src/useThrottledEffect/__docs__/story.mdx
@@ -0,0 +1,35 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks";
import { Example } from "./example.stories";

<Meta title="Lifecycle/useThrottledEffect" component={Example} />

# useThrottledEffect

Like `useEffect`, but passed function is throttled.

#### Example

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

## Reference

```ts
export function useThrottledEffect(
callback: (...args: any[]) => void,
deps: DependencyList,
delay: number,
noTrailing = false
): void;
```

#### Arguments

- **callback** _`(...args: any[]) => void`_ - Callback like for `useEffect`, but without ability to
return a cleanup function.
- **deps** _`DependencyList`_ - Dependencies list that will be passed to underlying `useEffect` and
`useThrottledCallback`.
- **delay** _`number`_ - Throttle delay.
- **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.
39 changes: 39 additions & 0 deletions src/useThrottledEffect/__tests__/dom.ts
@@ -0,0 +1,39 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { useThrottledEffect } from '../..';

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

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

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

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

it('should throttle passed callback', () => {
const spy = jest.fn();
const { rerender } = renderHook((dep) => useThrottledEffect(spy, [dep], 200, true), {
initialProps: 1,
});

expect(spy).toHaveBeenCalledTimes(1);
rerender(2);
rerender(3);
rerender(4);
expect(spy).toHaveBeenCalledTimes(1);

jest.advanceTimersByTime(200);
expect(spy).toHaveBeenCalledTimes(1);
rerender(5);
expect(spy).toHaveBeenCalledTimes(2);
});
});
21 changes: 21 additions & 0 deletions src/useThrottledEffect/__tests__/ssr.ts
@@ -0,0 +1,21 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useThrottledEffect } from '../..';

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

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

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

it('should render', () => {
const { result } = renderHook(() => useThrottledEffect(() => {}, [], 200));
expect(result.error).toBeUndefined();
});
});
25 changes: 25 additions & 0 deletions src/useThrottledEffect/useThrottledEffect.ts
@@ -0,0 +1,25 @@
import { DependencyList, useEffect } from 'react';
import { useThrottledCallback } from '..';

/**
* Like `useEffect`, but passed function is throttled.
*
* @param callback Callback like for `useEffect`, but without ability to return
* a cleanup function.
* @param deps Dependencies list that will be passed to underlying `useEffect`
* and `useThrottledCallback`.
* @param delay Throttle delay.
* @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 useThrottledEffect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callback: (...args: any[]) => void,
deps: DependencyList,
delay: number,
noTrailing = false
): void {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(useThrottledCallback(callback, deps, delay, noTrailing), deps);
}
15 changes: 15 additions & 0 deletions src/useThrottledState/__docs__/example.stories.tsx
@@ -0,0 +1,15 @@
import React from 'react';
import { useThrottledState } from '../..';

export const Example: React.FC = () => {
const [state, setState] = useThrottledState('', 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={(ev) => setState(ev.target.value)} />
</div>
);
};
37 changes: 37 additions & 0 deletions src/useThrottledState/__docs__/story.mdx
@@ -0,0 +1,37 @@
import {Canvas, Meta, Story} from '@storybook/addon-docs/blocks';
import {Example} from './example.stories';

<Meta title="State/useThrottledState" component={Example} />

# useThrottledState

Lise `useSafeState` but its state setter is throttled.

#### Example

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

## Reference

```ts
export function useThrottledState<S>(
initialState: S | (() => S),
delay: number,
noTrailing = false
): [S, Dispatch<SetStateAction<S>>];
```

#### Arguments

- **initialState** _`S | (() => S)`_ - Initial state to pass to underlying `useSafeState`.
- **delay** _`number`_ - Throttle delay.
- **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.

#### Return

0. **state** - current state.
1. **setState** - throttled state setter.

40 changes: 40 additions & 0 deletions src/useThrottledState/__tests__/dom.ts
@@ -0,0 +1,40 @@
import { renderHook, act } from '@testing-library/react-hooks/dom';
import { useThrottledState } from '../..';

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

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

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

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

it('should throttle set state', () => {
const { result } = renderHook(() => useThrottledState('', 200, true));

expect(result.current[0]).toBe('');
act(() => {
result.current[1]('hello world!');
});
expect(result.current[0]).toBe('hello world!');

result.current[1]('foo');
result.current[1]('bar');
expect(result.current[0]).toBe('hello world!');
jest.advanceTimersByTime(200);
act(() => {
result.current[1]('baz');
});
expect(result.current[0]).toBe('baz');
});
});
21 changes: 21 additions & 0 deletions src/useThrottledState/__tests__/ssr.ts
@@ -0,0 +1,21 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useThrottledState } from '../..';

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

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

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

it('should render', () => {
const { result } = renderHook(() => useThrottledState('', 200));
expect(result.error).toBeUndefined();
});
});
21 changes: 21 additions & 0 deletions src/useThrottledState/useThrottledState.ts
@@ -0,0 +1,21 @@
import { Dispatch, SetStateAction } from 'react';
import { useSafeState, useThrottledCallback } from '..';

/**
* Like `useSafeState` but its state setter is throttled.
*
* @param initialState Initial state to pass to underlying `useSafeState`.
* @param delay Throttle delay.
* @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 useThrottledState<S>(
initialState: S | (() => S),
delay: number,
noTrailing = false
): [S, Dispatch<SetStateAction<S>>] {
const [state, setState] = useSafeState(initialState);

return [state, useThrottledCallback(setState, [], delay, noTrailing)];
}

0 comments on commit 1cc6677

Please sign in to comment.