diff --git a/docs/useValidatableState.md b/docs/useStateValidator.md similarity index 50% rename from docs/useValidatableState.md rename to docs/useStateValidator.md index 1c84449e..0b434fca 100644 --- a/docs/useValidatableState.md +++ b/docs/useStateValidator.md @@ -1,17 +1,17 @@ -# `useValidatableState` +# `useStateValidator` -Very similar to React's `useState` hook, but extended with validation functionality. -Each time state changes validator invoked and it's result stored to separate state. +Each time given state changes - validator function is invoked. ## Usage ```ts import * as React from 'react'; import { useCallback } from 'react'; -import { useValidatableState } from 'react-use'; +import { useStateValidator } from 'react-use'; +const DemoStateValidator = s => [s === '' ? null : (s * 1) % 2 === 0]; const Demo = () => { - const validator = useCallback(s => [s === '' ? null : (s * 1) % 2 === 0], []); - const [state, setState, [isValid]] = useValidatableState(validator, ''); + const [state, setState] = React.useState(0); + const [[isValid]] = useStateValidator(state, DemoStateValidator); return (
@@ -21,7 +21,7 @@ const Demo = () => { min="0" max="10" value={state} - onChange={ev => { + onChange={(ev: React.ChangeEvent) => { setState(ev.target.value); }} /> @@ -33,16 +33,15 @@ const Demo = () => { ## Reference ```ts -const [state, setState, validity, revalidate] = useValidatableState( - validator: (state, prev, setValidity?)=>[boolean|null, ...any[]], - initialState: any +const [validity, revalidate] = useStateValidator( + state: any, + validator: (state, setValidity?)=>[boolean|null, ...any[]], + initialValidity: any ); ``` -- `state` and `setState` are the same with React's `useState` hook; - **`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`** should return an array suitable for validity state described above; +- **`validator`**_`: (state, setValidity?)=>[boolean|null, ...any[]]`_ should return an array suitable for validity state described above; - `state` - current state; - - `prev` - previous state; - `setValidity` - if defined hook will not trigger validity change automatically. Useful for async validators; -- `initialState` same with `useState` hook; +- `initialValidity` - validity value which set when validity is nt calculated yet; diff --git a/src/__stories__/useValidityState.story.tsx b/src/__stories__/useStateValidator.tsx similarity index 52% rename from src/__stories__/useValidityState.story.tsx rename to src/__stories__/useStateValidator.tsx index 66e9651e..906de0cb 100644 --- a/src/__stories__/useValidityState.story.tsx +++ b/src/__stories__/useStateValidator.tsx @@ -1,12 +1,12 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; -import { useCallback } from 'react'; -import { useValidatableState } from '../index'; +import useStateValidator from '../useStateValidator'; import ShowDocs from './util/ShowDocs'; +const DemoStateValidator = s => [s === '' ? null : (s * 1) % 2 === 0]; const Demo = () => { - const validator = useCallback(s => [s === '' ? null : (s * 1) % 2 === 0], []); - const [state, setState, [isValid]] = useValidatableState(validator, ''); + const [state, setState] = React.useState(0); + const [[isValid]] = useStateValidator(state, DemoStateValidator); return (
@@ -16,7 +16,7 @@ const Demo = () => { min="0" max="10" value={state} - onChange={ev => { + onChange={(ev: React.ChangeEvent) => { setState(ev.target.value); }} /> @@ -25,6 +25,6 @@ const Demo = () => { ); }; -storiesOf('State|useValidatableState', module) - .add('Docs', () => ) +storiesOf('State|useStateValidator', module) + .add('Docs', () => ) .add('Demo', () => ); diff --git a/src/__tests__/useStateValidator.test.ts b/src/__tests__/useStateValidator.test.ts new file mode 100644 index 00000000..7b717541 --- /dev/null +++ b/src/__tests__/useStateValidator.test.ts @@ -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 = jest.fn(state => [!!(state % 2)]) + ): [jest.Mock | Function, RenderHookResult]>] { + 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); + }); +}); diff --git a/src/__tests__/useValidatableState.test.ts b/src/__tests__/useValidatableState.test.ts deleted file mode 100644 index 0e0bc515..00000000 --- a/src/__tests__/useValidatableState.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; -import { useValidatableState } from '../index'; -import { UseValidatableStateReturn, Validator } from '../useValidatableState'; - -interface Mock extends jest.Mock {} - -describe('useValidatableState', () => { - it('should be defined', () => { - expect(useValidatableState).toBeDefined(); - }); - - function getHook( - fn: Validator = jest.fn(() => {}), - initialState: any = null - ): [Mock | Function, RenderHookResult<{ validator: Validator; init: any }, UseValidatableStateReturn>] { - return [ - fn, - renderHook(({ validator, init }) => useValidatableState(validator as Function, init), { - initialProps: { - validator: fn, - init: initialState, - }, - }), - ]; - } - - it('should return an array of four elements', () => { - const [, hook] = getHook(); - - expect(Array.isArray(hook.result.current)).toBe(true); - expect(hook.result.current.length).toBe(4); - }); - - it('first two elements should act like regular setState', () => { - const [, hook] = getHook(jest.fn(), 3); - const [, setState] = hook.result.current; - - expect(hook.result.current[0]).toBe(3); - act(() => setState(4)); - expect(hook.result.current[0]).toBe(4); - act(() => setState(prevState => prevState + 1)); - expect(hook.result.current[0]).toBe(5); - }); - - it('validator have to be called on init plus on each state update', () => { - const [spy, hook] = getHook(jest.fn(), 3); - 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('third element of returned array should represent validity state', () => { - const [, hook] = getHook(jest.fn(state => [state % 2 === 0]), 3); - let [, setState, [isValid]] = hook.result.current; - - 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); - }); - - it('should recalculate validity on validator change', () => { - const [, hook] = getHook(jest.fn(state => [state % 2 === 0]), 3); - let [, setState, [isValid]] = hook.result.current; - - expect(isValid).toBe(false); - - hook.rerender({ validator: jest.fn(state => [state % 2 === 1]), init: 3 }); - - [, setState, [isValid]] = hook.result.current; - expect(isValid).toBe(true); - act(() => setState(prevState => prevState + 1)); - - [, setState, [isValid]] = hook.result.current; - expect(isValid).toBe(false); - }); - - it('forth element of returned array should re-call validation', () => { - const [spy, hook] = getHook(jest.fn(), 3); - const [, , , validate] = hook.result.current; - - expect(spy).toHaveBeenCalledTimes(1); - act(() => validate()); - expect(spy).toHaveBeenCalledTimes(2); - }); - - it('should pass to validator two parameters: first - current state, second - previous state', () => { - const [spy, hook] = getHook(jest.fn(), 3); - const [, setState] = hook.result.current; - - act(() => setState(4)); - act(() => setState(prevState => prevState + 1)); - expect((spy as Mock).mock.calls[0][0]).toBe(3); - expect((spy as Mock).mock.calls[0][1]).toBe(null); - expect((spy as Mock).mock.calls[1][0]).toBe(4); - expect((spy as Mock).mock.calls[1][1]).toBe(3); - expect((spy as Mock).mock.calls[2][0]).toBe(5); - expect((spy as Mock).mock.calls[2][1]).toBe(4); - }); - - it('if validator expects 3 parameters it should pass a validity setter there', () => { - const [spy, hook] = getHook(jest.fn((state, _prevState, setValidity) => setValidity!([state % 2 === 0])), 3); - let [, setState, [isValid]] = hook.result.current; - - expect(typeof (spy as Mock).mock.calls[0][2]).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); - }); -}); diff --git a/src/index.ts b/src/index.ts index 412e1bd7..a3a933fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,7 +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 useValidatableState } from './useValidatableState'; +export { default as useStateValidator } from './useStateValidator'; export { useWait, Waiter } from './useWait'; export { default as useWindowScroll } from './useWindowScroll'; export { default as useWindowSize } from './useWindowSize'; diff --git a/src/useStateValidator.ts b/src/useStateValidator.ts new file mode 100644 index 00000000..cf9070f0 --- /dev/null +++ b/src/useStateValidator.ts @@ -0,0 +1,36 @@ +import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; + +export type ValidityState = [boolean | undefined, ...any[]]; +export type DispatchValidity = Dispatch>; + +export type Validator = + | { + (state?: S): V; + (state?: S, dispatch?: DispatchValidity): void; + } + | Function; + +export type UseValidatorReturn = [V, () => void]; + +export default function useStateValidator( + state: S, + validator: Validator, + initialValidity: V = [undefined] as V +): UseValidatorReturn { + 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]; +} diff --git a/src/useValidatableState.ts b/src/useValidatableState.ts deleted file mode 100644 index 2d0b9b46..00000000 --- a/src/useValidatableState.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; - -export type ValidityState = [boolean | null, ...any[]]; -export type DispatchValidityState = Dispatch>; - -export type Validator = - | { - (state?: State, prev?: State): StateValidity; - (state?: State, prev?: State, setValidity?: DispatchValidityState): void; - } - | Function; - -export type ValidateFn = () => void; - -export type UseValidatableStateReturn = [ - State, - Dispatch>, - StateValidity, - ValidateFn -]; - -export default function useValidatableState( - validator: Validator, - initialState?: State -): UseValidatableStateReturn { - const prevState = useRef(null); - const [state, setState] = useState(initialState!); - const [validity, setValidity] = useState([null] as StateValidity); - - const validate = useCallback(() => { - if (validator.length === 3) { - validator(state, prevState.current, setValidity as DispatchValidityState); - } else { - setValidity(validator(state, prevState.current)); - } - }, [state, validator]); - - useEffect(() => { - validate(); - }, [validate, state]); - useEffect(() => { - prevState.current = state; - }, [state]); - - return [state, setState, validity, validate]; -}