Repository: streamich/react-use
Pull request #588 (2019-09-04 01:17:34)

feat: useStateValidator
Handy for simple forms field validation;

The only thing i can't decide - should it automatically revalidate state on validator change or not.

Current impelemtation uses auto-revalidation, so it forces developer to wrap validator in `useCallback` by hand to avoid cycluc re-renders.
The other way is to add one more argument to the end something like `revalidateDeps` and wrap validator into `useCallback` automatically using these deps.

@streamich @Belco90 what do you think?


Patch:
diff --git a/README.md b/README.md
index 3ba51e52..fcc219a6 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,7 @@
   - [`useCounter` and `useNumber`](./docs/useCounter.md) &mdash; 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) &mdash; tracks state of an array. [![][img-demo]](https://codesandbox.io/s/wonderful-mahavira-1sm0w)
   - [`useMap`](./docs/useMap.md) &mdash; tracks state of an object. [![][img-demo]](https://codesandbox.io/s/quirky-dewdney-gi161)
+  - [`useStateValidator`](./docs/useStateValidator.md) &mdash; tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatevalidator--demo)
 
 
 <br />
diff --git a/docs/useStateValidator.md b/docs/useStateValidator.md
new file mode 100644
index 00000000..0b434fca
--- /dev/null
+++ b/docs/useStateValidator.md
@@ -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;
diff --git a/src/__stories__/useStateValidator.tsx b/src/__stories__/useStateValidator.tsx
new file mode 100644
index 00000000..906de0cb
--- /dev/null
+++ b/src/__stories__/useStateValidator.tsx
@@ -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 />);
diff --git a/src/index.ts b/src/index.ts
index b3514a71..a3a933fe 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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';
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<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];
+}


Test patch:
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<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);
+  });
+});