Repository: streamich/react-use
Pull request #676 (2019-10-14 17:42:48)

feat: useMediatedState
This hook will be usefull for forms with inputs and so on, now the function that processes raw input can be defined near the state, not "somwhere there, maybe inlined in jsx props".


Patch:
diff --git a/README.md b/README.md
index 0055c565..76fd92c1 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,7 @@
   - [`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)
   - [`useMultiStateValidator`](./docs/useMultiStateValidator.md) &mdash; alike the `useStateValidator`, but tracks multiple states at a time. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemultistatevalidator--demo)
+  - [`useMediatedState`](./docs/useMediatedState.md) &mdash; like the regular `useState` but with mediation by custom function. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemediatedstate--demo)
 
 <br />
 <br />
diff --git a/docs/useMediatedState.md b/docs/useMediatedState.md
new file mode 100644
index 00000000..0779a2e9
--- /dev/null
+++ b/docs/useMediatedState.md
@@ -0,0 +1,39 @@
+# `useMediatedState`
+
+A lot like the standard `useState`, but with mediation process.
+
+## Usage
+```ts
+import * as React from 'react';
+import { useMediatedState } from '../useMediatedState';
+
+const inputMediator = s => s.replace(/[\s]+/g, ' ');
+const Demo = () => {
+  const [state, setState] = useMediatedState(inputMediator, '');
+
+  return (
+    <div>
+      <div>You will not be able to enter more than one space</div>
+      <input type="text" min="0" max="10" 
+             value={state}
+             onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
+               setState(ev.target.value);
+             }}
+      />
+    </div>
+  );
+};
+```
+
+## Reference
+```ts
+const [state, setState] = useMediatedState<S=any>(
+  mediator: StateMediator<S>,
+  initialState?: S
+);
+```
+
+> Initial state will be set as-is.
+
+In case mediator expects 2 arguments it will receive the `setState` function as second argument, it is useful for async mediators.  
+>This hook will not cancel previous mediation when new one been invoked, you have to handle it yourself._
diff --git a/src/__stories__/useMediatedState.story.tsx b/src/__stories__/useMediatedState.story.tsx
new file mode 100644
index 00000000..96ee1a13
--- /dev/null
+++ b/src/__stories__/useMediatedState.story.tsx
@@ -0,0 +1,28 @@
+import { storiesOf } from '@storybook/react';
+import * as React from 'react';
+import { useMediatedState } from '../useMediatedState';
+import ShowDocs from './util/ShowDocs';
+
+const inputMediator = s => s.replace(/[\s]+/g, ' ');
+const Demo = () => {
+  const [state, setState] = useMediatedState(inputMediator, '');
+
+  return (
+    <div>
+      <div>You will not be able to enter more than one space</div>
+      <input
+        type="text"
+        min="0"
+        max="10"
+        value={state}
+        onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
+          setState(ev.target.value);
+        }}
+      />
+    </div>
+  );
+};
+
+storiesOf('State|useMediatedState', module)
+  .add('Docs', () => <ShowDocs md={require('../../docs/useMediatedState.md')} />)
+  .add('Demo', () => <Demo />);
diff --git a/src/index.ts b/src/index.ts
index eb213ed1..9c660296 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -44,6 +44,7 @@ export { default as useLogger } from './useLogger';
 export { default as useMap } from './useMap';
 export { default as useMedia } from './useMedia';
 export { default as useMediaDevices } from './useMediaDevices';
+export { useMediatedState } from './useMediatedState';
 export { default as useMotion } from './useMotion';
 export { default as useMount } from './useMount';
 export { default as useMountedState } from './useMountedState';
diff --git a/src/useMediatedState.ts b/src/useMediatedState.ts
new file mode 100644
index 00000000..708b2ecf
--- /dev/null
+++ b/src/useMediatedState.ts
@@ -0,0 +1,32 @@
+import { Dispatch, SetStateAction, useCallback, useRef, useState } from 'react';
+
+export interface StateMediator<S = any> {
+  (newState: any): S;
+
+  (newState: any, dispatch: Dispatch<SetStateAction<S>>): void;
+}
+
+export type UseMediatedStateReturn<S = any> = [S, Dispatch<SetStateAction<S>>];
+
+export function useMediatedState<S = undefined>(
+  mediator: StateMediator<S | undefined>
+): UseMediatedStateReturn<S | undefined>;
+export function useMediatedState<S = any>(mediator: StateMediator<S>, initialState: S): UseMediatedStateReturn<S>;
+
+export function useMediatedState<S = any>(mediator: StateMediator<S>, initialState?: S): UseMediatedStateReturn<S> {
+  const mediatorFn = useRef(mediator);
+
+  const [state, setMediatedState] = useState<S>(initialState!);
+  const setState = useCallback(
+    (newState: any) => {
+      if (mediatorFn.current.length === 2) {
+        mediatorFn.current(newState, setMediatedState);
+      } else {
+        setMediatedState(mediatorFn.current(newState));
+      }
+    },
+    [state]
+  );
+
+  return [state, setState];
+}


Test patch:
diff --git a/src/__tests__/useMediatedState.test.ts b/src/__tests__/useMediatedState.test.ts
new file mode 100644
index 00000000..ef9838a9
--- /dev/null
+++ b/src/__tests__/useMediatedState.test.ts
@@ -0,0 +1,68 @@
+import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks';
+import { Dispatch, SetStateAction } from 'react';
+import { useMediatedState } from '../';
+import { StateMediator, UseMediatedStateReturn } from '../useMediatedState';
+
+describe('useMediatedState', () => {
+  it('should be defined', () => {
+    expect(useMediatedState).toBeDefined();
+  });
+
+  function getHook(
+    initialState: number = 2,
+    fn: StateMediator<number> = jest.fn(newState => newState / 2)
+  ): [jest.Mock | StateMediator, RenderHookResult<any, UseMediatedStateReturn<number>>] {
+    return [fn, renderHook(() => useMediatedState<number>(fn, initialState))];
+  }
+
+  it('should return array of two elements', () => {
+    const [, hook] = getHook();
+
+    expect(Array.isArray(hook.result.current)).toBe(true);
+    expect(hook.result.current[0]).toBe(2);
+    expect(typeof hook.result.current[1]).toBe('function');
+  });
+
+  it('should act like regular useState but with mediator call on each setState', () => {
+    const [spy, hook] = getHook();
+
+    expect(hook.result.current[0]).toBe(2);
+
+    act(() => hook.result.current[1](3));
+    expect(hook.result.current[0]).toBe(1.5);
+    expect(spy).toHaveBeenCalledTimes(1);
+
+    act(() => hook.result.current[1](4));
+    expect(hook.result.current[0]).toBe(2);
+    expect(spy).toHaveBeenCalledTimes(2);
+  });
+
+  it('should not call mediator on init', () => {
+    const [spy] = getHook();
+
+    expect(spy).toHaveBeenCalledTimes(0);
+  });
+
+  it('mediator should receive setState argument as first argument', () => {
+    let val;
+    const spy = jest.fn(newState => {
+      val = newState;
+      return newState * 2;
+    });
+    const [, hook] = getHook(1, spy);
+
+    act(() => hook.result.current[1](3));
+    expect(val).toBe(3);
+    expect(hook.result.current[0]).toBe(6);
+  });
+
+  it('if mediator expects 2 args, second should be a function setting the state', () => {
+    const spy = (jest.fn((newState: number, setState: Dispatch<SetStateAction<number>>): void => {
+      setState(newState * 2);
+    }) as unknown) as StateMediator<number>;
+    const [, hook] = getHook(1, spy);
+
+    act(() => hook.result.current[1](3));
+    expect(hook.result.current[0]).toBe(6);
+  });
+});