Repository: streamich/react-use
Pull request #723 (2019-10-30 15:44:59)

feat: react-like state resolver
As it is done in React's `setState` this function receives new state as 1st parameter, it will be returned as-is if not a function and invoked otherwise receiving the 2nd parameter as a current state value like it is in react's implementation.

It will be very useful for stateful hooks to make the seamless usage comparing to react's hooks.
I have plans to move all the hooks to this implementation.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e35dfd8..411dc7d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,3 @@
-# [12.10.0](https://github.com/streamich/react-use/compare/v12.9.1...v12.10.0) (2019-10-30)
-
-
-### Features
-
-* **useUpdate:** improve memory usage - now single function instance to increment all counters; ([0f02fd0](https://github.com/streamich/react-use/commit/0f02fd0))
-
 ## [12.9.1](https://github.com/streamich/react-use/compare/v12.9.0...v12.9.1) (2019-10-28)
 
 
diff --git a/package.json b/package.json
index 9ed1d17f..fa369510 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "react-use",
-  "version": "12.10.0",
+  "version": "12.9.1",
   "description": "Collection of React Hooks",
   "main": "lib/index.js",
   "module": "esm/index.js",
diff --git a/src/useCounter.ts b/src/useCounter.ts
index 0c3fbae0..649829f9 100644
--- a/src/useCounter.ts
+++ b/src/useCounter.ts
@@ -1,85 +1,89 @@
-import { useCallback } from 'react';
+import { useMemo } from 'react';
 import useGetSet from './useGetSet';
+import { HookState, InitialHookState, resolveHookState } from './util/resolveHookState';
 
 export interface CounterActions {
   inc: (delta?: number) => void;
   dec: (delta?: number) => void;
   get: () => number;
-  set: (value: number) => void;
-  reset: (value?: number) => void;
+  set: (value: HookState<number>) => void;
+  reset: (value?: HookState<number>) => void;
 }
 
 export default function useCounter(
-  initialValue: number = 0,
+  initialValue: InitialHookState<number> = 0,
   max: number | null = null,
   min: number | null = null
 ): [number, CounterActions] {
-  typeof initialValue !== 'number' && console.error('initialValue has to be a number, got ' + typeof initialValue);
+  let init = resolveHookState(initialValue);
+
+  typeof init !== 'number' && console.error('initialValue has to be a number, got ' + typeof initialValue);
 
   if (typeof min === 'number') {
-    initialValue = Math.max(initialValue, min);
+    init = Math.max(init, min);
   } else if (min !== null) {
     console.error('min has to be a number, got ' + typeof min);
   }
 
   if (typeof max === 'number') {
-    initialValue = Math.min(initialValue, max);
+    init = Math.min(init, max);
   } else if (max !== null) {
     console.error('max has to be a number, got ' + typeof max);
   }
 
-  const [get, setInternal] = useGetSet<number>(initialValue);
+  const [get, setInternal] = useGetSet(init);
 
-  function set(value: number): void {
-    const current = get();
+  return [
+    get(),
+    useMemo(() => {
+      const set = (newState: HookState<number>) => {
+        const prevState = get();
+        let rState = resolveHookState(newState, prevState);
 
-    if (current === value) {
-      return;
-    }
+        if (prevState !== rState) {
+          if (typeof min === 'number') {
+            rState = Math.max(rState, min);
+          }
+          if (typeof max === 'number') {
+            rState = Math.min(rState, max);
+          }
 
-    if (typeof min === 'number') {
-      value = Math.max(value, min);
-    }
-    if (typeof max === 'number') {
-      value = Math.min(value, max);
-    }
+          prevState !== rState && setInternal(rState);
+        }
+      };
 
-    current !== value && setInternal(value);
-  }
+      return {
+        get,
+        set,
+        inc: (delta: HookState<number> = 1) => {
+          const rDelta = resolveHookState(delta, get());
+
+          if (typeof rDelta !== 'number') {
+            console.error('delta has to be a number or function returning a number, got ' + typeof rDelta);
+          }
+
+          set((num: number) => num + rDelta);
+        },
+        dec: (delta: HookState<number> = 1) => {
+          const rDelta = resolveHookState(delta, get());
+
+          if (typeof rDelta !== 'number') {
+            console.error('delta has to be a number or function returning a number, got ' + typeof rDelta);
+          }
+
+          set((num: number) => num - rDelta);
+        },
+        reset: (value: HookState<number> = init) => {
+          const rValue = resolveHookState(value, get());
+
+          if (typeof rValue !== 'number') {
+            console.error('value has to be a number or function returning a number, got ' + typeof rValue);
+          }
 
-  const inc = useCallback(
-    (delta: number = 1) => {
-      typeof delta !== 'number' && console.error('delta has to be a number, got ' + typeof delta);
-
-      set(get() + delta);
-    },
-    [max, min]
-  );
-  const dec = useCallback(
-    (delta: number = 1) => {
-      typeof delta !== 'number' && console.error('delta has to be a number, got ' + typeof delta);
-
-      set(get() - delta);
-    },
-    [max, min]
-  );
-  const reset = useCallback(
-    (value: number = initialValue) => {
-      typeof value !== 'number' && console.error('value has to be a number, got ' + typeof value);
-
-      initialValue = value;
-      set(value);
-    },
-    [max, min]
-  );
-
-  const actions = {
-    inc,
-    dec,
-    get,
-    set,
-    reset,
-  };
-
-  return [get(), actions];
+          init = rValue;
+          set(rValue);
+        },
+      };
+    }, [min, max]),
+  ];
 }
diff --git a/src/useGetSet.ts b/src/useGetSet.ts
index 33768aea..9cb4e021 100644
--- a/src/useGetSet.ts
+++ b/src/useGetSet.ts
@@ -1,16 +1,21 @@
-import { useCallback, useRef } from 'react';
+import { Dispatch, useMemo, useRef } from 'react';
 import useUpdate from './useUpdate';
+import { HookState, InitialHookState, resolveHookState } from './util/resolveHookState';
 
-const useGetSet = <T>(initialValue: T): [() => T, (value: T) => void] => {
-  const state = useRef(initialValue);
+export default function useGetSet<S>(initialState: InitialHookState<S>): [() => S, Dispatch<HookState<S>>] {
+  const state = useRef(resolveHookState(initialState));
   const update = useUpdate();
-  const get = useCallback(() => state.current, []);
-  const set = useCallback((value: T) => {
-    state.current = value;
-    update();
-  }, []);
 
-  return [get, set];
-};
-
-export default useGetSet;
+  return useMemo(
+    () => [
+      // get
+      () => state.current as S,
+      // set
+      (newState: HookState<S>) => {
+        state.current = resolveHookState(newState, state.current);
+        update();
+      },
+    ],
+    []
+  );
+}
diff --git a/src/useUpdate.ts b/src/useUpdate.ts
index e85142d0..495dc04e 100644
--- a/src/useUpdate.ts
+++ b/src/useUpdate.ts
@@ -1,11 +1,10 @@
-import { useCallback, useState } from 'react';
-
-const incrementParameter = (num: number): number => ++num;
+import { useState, useCallback } from 'react';
 
 const useUpdate = () => {
   const [, setState] = useState(0);
   // useCallback with empty deps as we only want to define updateCb once
-  return useCallback(() => setState(incrementParameter), []);
+  const updateCb = useCallback(() => setState(cnt => cnt + 1), []);
+  return updateCb;
 };
 
 export default useUpdate;
diff --git a/src/util/resolveHookState.ts b/src/util/resolveHookState.ts
new file mode 100644
index 00000000..18d39ee4
--- /dev/null
+++ b/src/util/resolveHookState.ts
@@ -0,0 +1,16 @@
+export type StateSetter<S> = (prevState: S) => S;
+export type InitialStateSetter<S> = () => S;
+
+export type InitialHookState<S> = S | InitialStateSetter<S>;
+export type HookState<S> = S | StateSetter<S>;
+export type ResolvableHookState<S> = S | StateSetter<S> | InitialStateSetter<S>;
+
+export function resolveHookState<S, C extends S>(newState: StateSetter<S>, currentState: C): S;
+export function resolveHookState<S, C extends S>(newState: ResolvableHookState<S>, currentState?: C): S;
+export function resolveHookState<S, C extends S>(newState: ResolvableHookState<S>, currentState?: C): S {
+  if (typeof newState === 'function') {
+    return (newState as Function)(currentState);
+  }
+
+  return newState;
+}


Test patch:
diff --git a/src/__tests__/resolveHookState.ts b/src/__tests__/resolveHookState.ts
new file mode 100644
index 00000000..520136bf
--- /dev/null
+++ b/src/__tests__/resolveHookState.ts
@@ -0,0 +1,26 @@
+import { resolveHookState } from '../util/resolveHookState';
+
+describe('resolveHookState', () => {
+  it('should defined', () => {
+    expect(resolveHookState).toBeDefined();
+  });
+
+  it(`should return value as is if it's not a function`, () => {
+    expect(resolveHookState(1)).toBe(1);
+    expect(resolveHookState('HI!')).toBe('HI!');
+    expect(resolveHookState(undefined)).toBe(undefined);
+  });
+
+  it('should call passed function', () => {
+    const spy = jest.fn();
+    resolveHookState(spy);
+    expect(spy).toHaveBeenCalled();
+  });
+
+  it('should pass 2nd parameter to function', () => {
+    const spy = jest.fn();
+    resolveHookState(spy, 123);
+    expect(spy).toHaveBeenCalled();
+    expect(spy.mock.calls[0][0]).toBe(123);
+  });
+});
diff --git a/src/__tests__/useCounter.test.ts b/src/__tests__/useCounter.test.ts
index 766243ce..1eb8734f 100644
--- a/src/__tests__/useCounter.test.ts
+++ b/src/__tests__/useCounter.test.ts
@@ -187,15 +187,15 @@ describe('should `console.error` on unexpected inputs', () => {
 
     // @ts-ignore
     act(() => inc(false));
-    expect(spy.mock.calls[0][0]).toBe('delta has to be a number, got boolean');
+    expect(spy.mock.calls[0][0]).toBe('delta has to be a number or function returning a number, got boolean');
 
     // @ts-ignore
     act(() => dec(false));
-    expect(spy.mock.calls[1][0]).toBe('delta has to be a number, got boolean');
+    expect(spy.mock.calls[1][0]).toBe('delta has to be a number or function returning a number, got boolean');
 
     // @ts-ignore
     act(() => reset({}));
-    expect(spy.mock.calls[2][0]).toBe('value has to be a number, got object');
+    expect(spy.mock.calls[2][0]).toBe('value has to be a number or function returning a number, got object');
 
     spy.mockRestore();
   });