Skip to content

Commit

Permalink
Merge pull request #588 from streamich/feat-useValidatableState
Browse files Browse the repository at this point in the history
feat: useStateValidator
  • Loading branch information
xobotyi authored Oct 11, 2019
2 parents 655c49d + 9ab8698 commit d4aec7a
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
- [`useCounter` and `useNumber`](./docs/useCounter.md) — tracks state of a number. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usecounter--demo)
- [`useList`](./docs/useList.md) and [`useUpsert`](./docs/useUpsert.md) — tracks state of an array. [![][img-demo]](https://codesandbox.io/s/wonderful-mahavira-1sm0w)
- [`useMap`](./docs/useMap.md) — tracks state of an object. [![][img-demo]](https://codesandbox.io/s/quirky-dewdney-gi161)
- [`useStateValidator`](./docs/useStateValidator.md) — tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatevalidator--demo)


<br />
Expand Down
47 changes: 47 additions & 0 deletions docs/useStateValidator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# `useStateValidator`

Each time given state changes - validator function is invoked.

## Usage
```ts
import * as React from 'react';
import { useCallback } from 'react';
import { useStateValidator } from 'react-use';

const DemoStateValidator = s => [s === '' ? null : (s * 1) % 2 === 0];
const Demo = () => {
const [state, setState] = React.useState<string | number>(0);
const [[isValid]] = useStateValidator(state, DemoStateValidator);

return (
<div>
<div>Below field is valid only if number is even</div>
<input
type="number"
min="0"
max="10"
value={state}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setState(ev.target.value);
}}
/>
{isValid !== null && <span>{isValid ? 'Valid!' : 'Invalid'}</span>}
</div>
);
};
```

## Reference
```ts
const [validity, revalidate] = useStateValidator(
state: any,
validator: (state, setValidity?)=>[boolean|null, ...any[]],
initialValidity: any
);
```
- **`validity`**_`: [boolean|null, ...any[]]`_ result of validity check. First element is strictly nullable boolean, but others can contain arbitrary data;
- **`revalidate`**_`: ()=>void`_ runs validator once again
- **`validator`**_`: (state, setValidity?)=>[boolean|null, ...any[]]`_ should return an array suitable for validity state described above;
- `state` - current state;
- `setValidity` - if defined hook will not trigger validity change automatically. Useful for async validators;
- `initialValidity` - validity value which set when validity is nt calculated yet;
30 changes: 30 additions & 0 deletions src/__stories__/useStateValidator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import useStateValidator from '../useStateValidator';
import ShowDocs from './util/ShowDocs';

const DemoStateValidator = s => [s === '' ? null : (s * 1) % 2 === 0];
const Demo = () => {
const [state, setState] = React.useState<string | number>(0);
const [[isValid]] = useStateValidator(state, DemoStateValidator);

return (
<div>
<div>Below field is valid only if number is even</div>
<input
type="number"
min="0"
max="10"
value={state}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setState(ev.target.value);
}}
/>
{isValid !== null && <span>{isValid ? 'Valid!' : 'Invalid'}</span>}
</div>
);
};

storiesOf('State|useStateValidator', module)
.add('Docs', () => <ShowDocs md={require('../../docs/useStateValidator.md')} />)
.add('Demo', () => <Demo />);
100 changes: 100 additions & 0 deletions src/__tests__/useStateValidator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks';
import { useState } from 'react';
import useStateValidator, { UseValidatorReturn, Validator } from '../useStateValidator';

interface Mock extends jest.Mock {}

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

function getHook(
fn: Validator<any> = jest.fn(state => [!!(state % 2)])
): [jest.Mock | Function, RenderHookResult<any, [Function, UseValidatorReturn<any>]>] {
return [
fn,
renderHook(() => {
const [state, setState] = useState(1);

return [setState, useStateValidator(state, fn)];
}),
];
}

it('should return an array of two elements', () => {
const [, hook] = getHook();
const res = hook.result.current[1];

expect(Array.isArray(res)).toBe(true);
expect(res[0]).toEqual([true]);
expect(typeof res[1]).toBe('function');
});

it('first element should represent current validity state', () => {
const [, hook] = getHook();
let [setState, [validity]] = hook.result.current;
expect(validity).toEqual([true]);

act(() => setState(3));
[setState, [validity]] = hook.result.current;
expect(validity).toEqual([true]);

act(() => setState(4));
[setState, [validity]] = hook.result.current;
expect(validity).toEqual([false]);
});

it('second element should re-call validation', () => {
const [spy, hook] = getHook();
const [, [, revalidate]] = hook.result.current;

expect(spy).toHaveBeenCalledTimes(1);
act(() => revalidate());
expect(spy).toHaveBeenCalledTimes(2);
});

it('validator have to be called on init plus on each state update', () => {
const [spy, hook] = getHook(jest.fn());
const [setState] = hook.result.current;

expect(spy).toHaveBeenCalledTimes(1);
act(() => setState(4));
expect(spy).toHaveBeenCalledTimes(2);
act(() => setState(prevState => prevState + 1));
expect(spy).toHaveBeenCalledTimes(3);
});

it('should pass to validator one parameter - current state', () => {
const [spy, hook] = getHook(jest.fn());
const [setState] = hook.result.current;

act(() => setState(4));
act(() => setState(5));
expect((spy as Mock).mock.calls[0].length).toBe(1);
expect((spy as Mock).mock.calls[0].length).toBe(1);
expect((spy as Mock).mock.calls[0][0]).toBe(1);
expect((spy as Mock).mock.calls[1].length).toBe(1);
expect((spy as Mock).mock.calls[1][0]).toBe(4);
expect((spy as Mock).mock.calls[2].length).toBe(1);
expect((spy as Mock).mock.calls[2][0]).toBe(5);
});

it('if validator expects 2nd parameters it should pass a validity setter there', () => {
const [spy, hook] = getHook(jest.fn((state, setValidity) => setValidity!([state % 2 === 0])));
let [setState, [[isValid]]] = hook.result.current;

expect((spy as Mock).mock.calls[0].length).toBe(2);
expect(typeof (spy as Mock).mock.calls[0][1]).toBe('function');

expect(isValid).toBe(false);
act(() => setState(prevState => prevState + 1));

[setState, [[isValid]]] = hook.result.current;
expect(isValid).toBe(true);
act(() => setState(5));

[setState, [[isValid]]] = hook.result.current;
expect(isValid).toBe(false);
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export { default as useUpdate } from './useUpdate';
export { default as useUpdateEffect } from './useUpdateEffect';
export { default as useUpsert } from './useUpsert';
export { default as useVideo } from './useVideo';
export { default as useStateValidator } from './useStateValidator';
export { useWait, Waiter } from './useWait';
export { default as useWindowScroll } from './useWindowScroll';
export { default as useWindowSize } from './useWindowSize';
Expand Down
36 changes: 36 additions & 0 deletions src/useStateValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react';

export type ValidityState = [boolean | undefined, ...any[]];
export type DispatchValidity<V extends ValidityState> = Dispatch<SetStateAction<V>>;

export type Validator<V extends ValidityState, S = any> =
| {
(state?: S): V;
(state?: S, dispatch?: DispatchValidity<V>): void;
}
| Function;

export type UseValidatorReturn<V extends ValidityState> = [V, () => void];

export default function useStateValidator<V extends ValidityState, S = any>(
state: S,
validator: Validator<V, S>,
initialValidity: V = [undefined] as V
): UseValidatorReturn<V> {
const validatorFn = useRef(validator);

const [validity, setValidity] = useState(initialValidity);
const validate = useCallback(() => {
if (validatorFn.current.length === 2) {
validatorFn.current(state, setValidity);
} else {
setValidity(validatorFn.current(state));
}
}, [state]);

useEffect(() => {
validate();
}, [state]);

return [validity, validate];
}

0 comments on commit d4aec7a

Please sign in to comment.