diff --git a/README.md b/README.md index 870e5ceb..edd93dcd 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ our [migration guide](https://react-hookz.github.io/web/?path=/docs/migrating-fr — Like `useSafeState` but its state setter is throttled. - [**`useValidator`**](https://react-hookz.github.io/web/?path=/docs/state-usevalidator--example) — Performs validation when any of provided dependencies has changed. + - [**`useGetSet`**](https://react-hookz.github.io/web/?path=/docs/state-usegetset--example) + - React state hook that returns state getter function instead of raw state itself, this prevents subtle bugs when state is used in nested functions. - #### Navigator diff --git a/src/__docs__/migrating-from-react-use.story.mdx b/src/__docs__/migrating-from-react-use.story.mdx index b8344915..197d178e 100644 --- a/src/__docs__/migrating-from-react-use.story.mdx +++ b/src/__docs__/migrating-from-react-use.story.mdx @@ -557,7 +557,7 @@ Not implemented yet #### useGetSet -Not implemented yet +See [useGetSet](/docs/state-useGetSet--example) #### useGetSetState diff --git a/src/index.ts b/src/index.ts index 4cf2f65e..028f767a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export { useMap } from './useMap/useMap'; export { useMediatedState } from './useMediatedState/useMediatedState'; export { usePrevious } from './usePrevious/usePrevious'; export { useSafeState } from './useSafeState/useSafeState'; +export { useGetSet } from './useGetSet/useGetSet'; export { useSet } from './useSet/useSet'; export { useToggle } from './useToggle/useToggle'; export { useThrottledState } from './useThrottledState/useThrottledState'; diff --git a/src/useGetSet/__docs__/example.stories.tsx b/src/useGetSet/__docs__/example.stories.tsx new file mode 100644 index 00000000..5c8f89c8 --- /dev/null +++ b/src/useGetSet/__docs__/example.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { useGetSet } from '../..'; + +export const Example: React.FC = () => { + const [get, set] = useGetSet(0); + const onClick = () => { + setTimeout(() => { + set(get() + 1); + }, 1000); + }; + return ; +}; diff --git a/src/useGetSet/__docs__/story.mdx b/src/useGetSet/__docs__/story.mdx new file mode 100644 index 00000000..e2218066 --- /dev/null +++ b/src/useGetSet/__docs__/story.mdx @@ -0,0 +1,38 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './example.stories'; +import { ImportPath } from '../../storybookUtil/ImportPath'; + + + +# useGetSet + +React state hook that returns state getter function instead of raw state itself, this prevents subtle bugs when state is used in nested functions. + +#### Example + + + + + +## Reference + +```ts +export function useGetSet( + initialState: IInitialState +): [get: () => S, set: (nextState: INextState) => void]; +``` + +#### Importing + + + +#### Arguments + +- _**initialState**_ _`IInitialState`_ - initial state or initial state setter as for `useState` + +#### Return + +Returns array alike `useState` does, but instead return a state value it return a getter function. + +- _**[0]**_ _`S`_ - getter of current state +- _**[1]**_ _`(nextState?: INewState) => void`_ - state setter as for `useState`. diff --git a/src/useGetSet/__tests__/dom.ts b/src/useGetSet/__tests__/dom.ts new file mode 100644 index 00000000..84fdeb7f --- /dev/null +++ b/src/useGetSet/__tests__/dom.ts @@ -0,0 +1,62 @@ +import { renderHook, act } from '@testing-library/react-hooks/dom'; +import { useGetSet } from '../..'; + +const setUp = (initialValue: any) => renderHook(() => useGetSet(initialValue)); + +beforeEach(() => { + jest.useFakeTimers(); +}); + +describe('useGetSet', () => { + it('should be defined', () => { + expect(useGetSet).toBeDefined(); + }); + + it('should render', () => { + const { result } = setUp('foo'); + expect(result.error).toBeUndefined(); + }); + + it('should init getter and setter', () => { + const { result } = setUp('foo'); + const [get, set] = result.current; + expect(get).toBeInstanceOf(Function); + expect(set).toBeInstanceOf(Function); + }); + + it('should get current value', () => { + const { result } = setUp('foo'); + const [get] = result.current; + expect(get()).toBe('foo'); + }); + + it('should set new value', () => { + const { result } = setUp('foo'); + const [get, set] = result.current; + act(() => set('bar')); + const currentValue = get(); + expect(currentValue).toBe('bar'); + }); + + it('should get and set expected values when used in nested functions', () => { + const { result } = setUp(0); + const [get, set] = result.current; + const onClick = jest.fn(() => { + setTimeout(() => { + set(get() + 1); + }, 1000); + }); + + // simulate 3 clicks + onClick(); + onClick(); + onClick(); + + act(() => { + jest.runAllTimers(); + }); + + expect(get()).toBe(3); + expect(onClick).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/useGetSet/__tests__/ssr.ts b/src/useGetSet/__tests__/ssr.ts new file mode 100644 index 00000000..107819c0 --- /dev/null +++ b/src/useGetSet/__tests__/ssr.ts @@ -0,0 +1,62 @@ +import { renderHook, act } from '@testing-library/react-hooks/server'; +import { useGetSet } from '../..'; + +const setUp = (initialValue: any) => renderHook(() => useGetSet(initialValue)); + +beforeEach(() => { + jest.useFakeTimers(); +}); + +describe('useGetSet', () => { + it('should be defined', () => { + expect(useGetSet).toBeDefined(); + }); + + it('should render', () => { + const { result } = setUp('foo'); + expect(result.error).toBeUndefined(); + }); + + it('should init getter and setter', () => { + const { result } = setUp('foo'); + const [get, set] = result.current; + expect(get).toBeInstanceOf(Function); + expect(set).toBeInstanceOf(Function); + }); + + it('should get current value', () => { + const { result } = setUp('foo'); + const [get] = result.current; + expect(get()).toBe('foo'); + }); + + it('should set new value', () => { + const { result } = setUp('foo'); + const [get, set] = result.current; + act(() => set('bar')); + const currentValue = get(); + expect(currentValue).toBe('bar'); + }); + + it('should get and set expected values when used in nested functions', () => { + const { result } = setUp(0); + const [get, set] = result.current; + const onClick = jest.fn(() => { + setTimeout(() => { + set(get() + 1); + }, 1000); + }); + + // simulate 3 clicks + onClick(); + onClick(); + onClick(); + + act(() => { + jest.runAllTimers(); + }); + + expect(get()).toBe(3); + expect(onClick).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/useGetSet/useGetSet.ts b/src/useGetSet/useGetSet.ts new file mode 100644 index 00000000..e198dc78 --- /dev/null +++ b/src/useGetSet/useGetSet.ts @@ -0,0 +1,13 @@ +import { useCallback, Dispatch, SetStateAction } from 'react'; +import { IInitialState } from '../util/resolveHookState'; +import { useSafeState, useSyncedRef } from '../index'; + +export function useGetSet( + initialState: IInitialState +): [get: () => S, set: Dispatch>] { + const [state, setState] = useSafeState(initialState); + const stateRef = useSyncedRef(state); + + // eslint-disable-next-line react-hooks/exhaustive-deps + return [useCallback(() => stateRef.current, []), setState]; +}