Skip to content

Commit

Permalink
feat: new hooks useDebouncedEffect and useDebouncedState (#133)
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi authored Jun 16, 2021
1 parent 131d98e commit 1d164ff
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 2 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";
— Like `useEffect` but callback invoked only if conditions match predicate.
- [**`useConditionalUpdateEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionalupdateeffect)
— Like `useUpdateEffect` but callback invoked only if conditions match predicate.
- [**`useDebouncedEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usedebouncedeffect)
— Like `useEffect`, but passed function is debounced.
- [**`useFirstMountState`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usefirstmountstate)
— Return boolean that is `true` only on first render.
- [**`useIsMounted`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-useismounted)
Expand All @@ -90,6 +92,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";

- #### State

- [**`useDebouncedState`**](https://react-hookz.github.io/web/?path=/docs/state-usedebouncedstate)
— Lise `useSafeState` but its state setter is debounced.
- [**`useMediatedState`**](https://react-hookz.github.io/web/?path=/docs/state-usemediatedstate)
— Like `useState`, but every value set is passed through a mediator function.
- [**`usePrevious`**](https://react-hookz.github.io/web/?path=/docs/state-useprevious)
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,7 @@ export { useMediaQuery } from './useMediaQuery/useMediaQuery';

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

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

export { useDebouncedState } from './useDebouncedState/useDebouncedState';
36 changes: 36 additions & 0 deletions src/useDebouncedEffect/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react';
import { useState } from 'react';
import { useDebouncedEffect } from '../..';

const HAS_DIGIT_REGEX = /[\d]/g;

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

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

return (
<div>
<div>
Digit check will be performed 200ms after last change, but at least once every 500ms
</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/useDebouncedEffect/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

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

# useDebouncedEffect

Like `useEffect`, but passed function is debounced.

#### Example

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

## Reference

```ts
export function useDebouncedEffect(
callback: (...args: any[]) => void,
deps: DependencyList,
delay: number,
maxWait = 0
): 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
`useDebouncedCallback`.
- **delay** _`number`_ - Debounce delay.
- **maxWait** _`number`_ _(default: `0`)_ The maximum time `callback` is allowed to be delayed
before it's invoked. `0` means no max wait.
34 changes: 34 additions & 0 deletions src/useDebouncedEffect/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { useDebouncedEffect } from '../..';

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

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

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

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

it('should call effect only after delay', () => {
const spy = jest.fn();

renderHook(() => useDebouncedEffect(spy, [], 200));
expect(spy).not.toHaveBeenCalled();

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

jest.advanceTimersByTime(1);
expect(spy).toHaveBeenCalledTimes(1);
});
});
21 changes: 21 additions & 0 deletions src/useDebouncedEffect/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useDebouncedEffect } from '../..';

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

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

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

it('should render', () => {
const { result } = renderHook(() => useDebouncedEffect(() => {}, [], 200));
expect(result.error).toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions src/useDebouncedEffect/useDebouncedEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { DependencyList, useEffect } from 'react';
import { useDebouncedCallback } from '..';

/**
* Like `useEffect`, but passed function is debounced.
*
* @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 `useDebouncedCallback`.
* @param delay Debounce delay.
* @param maxWait Maximum amount of milliseconds that function can be delayed
* before it's force execution. 0 means no max wait.
*/
export function useDebouncedEffect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callback: (...args: any[]) => void,
deps: DependencyList,
delay: number,
maxWait = 0
): void {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(useDebouncedCallback(callback, deps, delay, maxWait), deps);
}
20 changes: 20 additions & 0 deletions src/useDebouncedState/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react';
import { useDebouncedState } from '../..';

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

return (
<div>
<div>Below state will update 200ms after last change, but at least once every 500ms</div>
<br />
<div>The input`s value is: {state}</div>
<input
type="text"
onChange={(ev) => {
setState(ev.target.value);
}}
/>
</div>
);
};
36 changes: 36 additions & 0 deletions src/useDebouncedState/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

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

# useDebouncedState

Lise `useSafeState` but its state setter is debounced.

#### Example

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

## Reference

```ts
export function useDebouncedState<S>(
initialState: S | (() => S),
delay: number,
maxWait = 0
): [S, Dispatch<SetStateAction<S>>];
```

#### Arguments

- **initialState** _`S | (() => S)`_ - Initial state to pass to underlying `useSafeState`.
- **delay** _`number`_ - Debounce delay.
- **maxWait** _`number`_ _(default: `0`)_ - The maximum time `callback` is allowed to be delayed
before it's invoked. `0` means no max wait.

#### Return

0. **state** - current state.
1. **setState** - debounced state setter.
36 changes: 36 additions & 0 deletions src/useDebouncedState/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { act, renderHook } from '@testing-library/react-hooks/dom';
import { useDebouncedState } from '../..';

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

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

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

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

it('should ', () => {
act(() => {
const { result } = renderHook(() => useDebouncedState<string | undefined>(undefined, 200));

expect(result.current[0]).toBe(undefined);
result.current[1]('Hello world!');

jest.advanceTimersByTime(199);
expect(result.current[0]).toBe(undefined);

jest.advanceTimersByTime(1);
expect(result.current[0]).toBe('Hello world!');
});
});
});
21 changes: 21 additions & 0 deletions src/useDebouncedState/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useDebouncedState } from '../..';

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

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

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

it('should render', () => {
const { result } = renderHook(() => useDebouncedState(undefined, 200));
expect(result.error).toBeUndefined();
});
});
20 changes: 20 additions & 0 deletions src/useDebouncedState/useDebouncedState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Dispatch, SetStateAction } from 'react';
import { useDebouncedCallback, useSafeState } from '..';

/**
* Lise `useSafeState` but its state setter is debounced.
*
* @param initialState Initial state to pass to underlying `useSafeState`.
* @param delay Debounce delay.
* @param maxWait Maximum amount of milliseconds that function can be delayed
* before it's force execution. 0 means no max wait.
*/
export function useDebouncedState<S>(
initialState: S | (() => S),
delay: number,
maxWait = 0
): [S, Dispatch<SetStateAction<S>>] {
const [state, setState] = useSafeState(initialState);

return [state, useDebouncedCallback(setState, [], delay, maxWait)];
}
2 changes: 0 additions & 2 deletions src/useSafeState/useSafeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ export function useSafeState<S = undefined>(): [

/**
* Like `useState` but its state setter is guarded against sets on unmounted component.
*
* @param initialState
*/
export function useSafeState<S>(
initialState?: S | (() => S)
Expand Down

0 comments on commit 1d164ff

Please sign in to comment.