Skip to content

Commit

Permalink
feat: implement useControlledRerenderState hook (#865)
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi authored Jul 4, 2022
1 parent 085f711 commit ea4545b
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 2 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ Coming from `react-use`? Check out our

- #### State

- [**`useControlledRerenderState`**](https://react-hookz.github.io/web/?path=/docs/state-usecontrolledrerenderstate--example)
— Like `React.useState`, but its state setter accepts extra argument, that allows to cancel
rerender.
- [**`useCounter`**](https://react-hookz.github.io/web/?path=/docs/state-usecounter--example)
— Tracks a numeric value and offers functions for manipulating it.
- [**`useDebouncedState`**](https://react-hookz.github.io/web/?path=/docs/state-usedebouncedstate--example)
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export { useLifecycleLogger } from './useLifecycleLogger/useLifecycleLogger';
export { useIntervalEffect } from './useIntervalEffect/useIntervalEffect';

// State
export { useControlledRerenderState } from './useControlledRerenderState/useControlledRerenderState';
export { useCounter, CounterActions } from './useCounter/useCounter';
export { useDebouncedState } from './useDebouncedState/useDebouncedState';
export { useFunctionalState } from './useFunctionalState/useFunctionalState';
export { useList } from './useList/useList';
Expand All @@ -46,7 +48,6 @@ export {
IValidityState,
IUseValidatorReturn,
} from './useValidator/useValidator';
export { useCounter, CounterActions } from './useCounter/useCounter';

// Navigator
export {
Expand Down Expand Up @@ -111,3 +112,5 @@ export { useWindowSize, WindowSize } from './useWindowSize/useWindowSize';
export { truthyAndArrayPredicate, truthyOrArrayPredicate } from './util/const';

export { IEffectCallback, IEffectHook } from './util/misc';

export { resolveHookState } from './util/resolveHookState';
27 changes: 27 additions & 0 deletions src/useControlledRerenderState/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from 'react';
import { useControlledRerenderState, useToggle } from '../..';

export const Example: React.FC = () => {
const [state, setState] = useControlledRerenderState(0);
const [doRerender, toggleDoRerender] = useToggle(true);

return (
<div>
<div>State: {state}</div>
<p>
<button
onClick={() => {
setState((s) => s + 1, doRerender);
}}>
Increment (+1)
</button>{' '}
<button
onClick={() => {
toggleDoRerender();
}}>
{doRerender ? 'Disable' : 'Enable'} re-rendering on state set
</button>
</p>
</div>
);
};
45 changes: 45 additions & 0 deletions src/useControlledRerenderState/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';
import { ImportPath } from '../../storybookUtil/ImportPath';

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

# useControlledRerenderState

Like `React.useState`, but its state setter accepts extra argument, that allows to cancel rerender.

- Allows to avoid rerender during state set.

#### Example

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

## Reference

```ts
export type ControlledRerenderDispatch<A> = (value: A, rerender?: boolean) => void;

export function useControlledRerenderState<S>(
initialState: S | (() => S)
): [S, ControlledRerenderDispatch<React.SetStateAction<S>>];
export function useControlledRerenderState<S = undefined>(): [
S | undefined,
ControlledRerenderDispatch<React.SetStateAction<S | undefined>>
];
```

#### Importing

<ImportPath />

#### Arguments

Identical to `React.useState`.

#### Return

Returned state setter accepts extra-argument, in case it is set to false - component will not be
re-rendered after state set.
In case extra parameter omitted - state setter behaves exactly as native.
45 changes: 45 additions & 0 deletions src/useControlledRerenderState/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { act, renderHook } from '@testing-library/react-hooks/dom';
import { useControlledRerenderState } from '../..';

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

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

it('should behave as `useState` by default', () => {
const { result } = renderHook(() => useControlledRerenderState(() => 0));

expect(result.current[0]).toBe(0);

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

act(() => {
result.current[1]((i) => i + 3);
});
expect(result.current[0]).toBe(4);
});

it('should not re-render in case setter extra-argument set to false', () => {
const { result } = renderHook(() => useControlledRerenderState(() => 0));

expect(result.current[0]).toBe(0);

act(() => {
result.current[1](1, false);
});
expect(result.current[0]).toBe(0);

act(() => {
result.current[1]((i) => i + 3);
});
expect(result.current[0]).toBe(4);
});
});
13 changes: 13 additions & 0 deletions src/useControlledRerenderState/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useControlledRerenderState } from '../..';

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

it('should render', () => {
const { result } = renderHook(() => useControlledRerenderState());
expect(result.error).toBeUndefined();
});
});
42 changes: 42 additions & 0 deletions src/useControlledRerenderState/useControlledRerenderState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { SetStateAction, useCallback, useRef } from 'react';
import { useFirstMountState, useRerender } from '..';
import { resolveHookState } from '../util/resolveHookState';

export type ControlledRerenderDispatch<A> = (value: A, rerender?: boolean) => void;

export function useControlledRerenderState<S>(
initialState: S | (() => S)
): [S, ControlledRerenderDispatch<SetStateAction<S>>];
export function useControlledRerenderState<S = undefined>(): [
S | undefined,
ControlledRerenderDispatch<SetStateAction<S | undefined>>
];

/**
* Like `React.useState`, but its state setter accepts extra argument, that allows to cancel
* rerender.
*/
export function useControlledRerenderState<S>(
initialState?: S | (() => S)
): [S | undefined, ControlledRerenderDispatch<SetStateAction<S | undefined>>] {
const state = useRef<S | undefined>(
useFirstMountState() ? resolveHookState(initialState) : undefined
);
const rr = useRerender();

return [
state.current,
useCallback((value, rerender) => {
const newState = resolveHookState(value, state.current);

if (newState !== state.current) {
state.current = newState;

if (typeof rerender === 'undefined' || rerender) {
rr();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []),
];
}
2 changes: 1 addition & 1 deletion src/useHookableRef/useHookableRef.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MutableRefObject, useMemo } from 'react';
import { useSyncedRef } from '../useSyncedRef/useSyncedRef';
import { useSyncedRef } from '..';

export type HookableRefHandler<T> = (v: T) => T;

Expand Down

0 comments on commit ea4545b

Please sign in to comment.