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(); }); 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) => void; + reset: (value?: HookState) => void; } export default function useCounter( - initialValue: number = 0, + initialValue: InitialHookState = 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(initialValue); + const [get, setInternal] = useGetSet(init); - function set(value: number): void { - const current = get(); + return [ + get(), + useMemo(() => { + const set = (newState: HookState) => { + 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 = 1) => { + const rDelta = resolveHookState(delta, get()); - const inc = useCallback( - (delta: number = 1) => { - typeof delta !== 'number' && console.error('delta has to be a number, got ' + typeof delta); + if (typeof rDelta !== 'number') { + console.error('delta has to be a number or function returning a number, got ' + typeof rDelta); + } - 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((num: number) => num + rDelta); + }, + dec: (delta: HookState = 1) => { + const rDelta = resolveHookState(delta, get()); - 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); + if (typeof rDelta !== 'number') { + console.error('delta has to be a number or function returning a number, got ' + typeof rDelta); + } - initialValue = value; - set(value); - }, - [max, min] - ); + set((num: number) => num - rDelta); + }, + reset: (value: HookState = init) => { + const rValue = resolveHookState(value, get()); - const actions = { - inc, - dec, - get, - set, - reset, - }; + if (typeof rValue !== 'number') { + console.error('value has to be a number or function returning a number, got ' + typeof rValue); + } - 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 = (initialValue: T): [() => T, (value: T) => void] => { - const state = useRef(initialValue); +export default function useGetSet(initialState: InitialHookState): [() => S, Dispatch>] { + 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) => { + state.current = resolveHookState(newState, state.current); + update(); + }, + ], + [] + ); +} 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 = (prevState: S) => S; +export type InitialStateSetter = () => S; + +export type InitialHookState = S | InitialStateSetter; +export type HookState = S | StateSetter; +export type ResolvableHookState = S | StateSetter | InitialStateSetter; + +export function resolveHookState(newState: StateSetter, currentState: C): S; +export function resolveHookState(newState: ResolvableHookState, currentState?: C): S; +export function resolveHookState(newState: ResolvableHookState, currentState?: C): S { + if (typeof newState === 'function') { + return (newState as Function)(currentState); + } + + return newState; +}