From 13c374cbe0a3a01b40dc624889a89c9f4ed0708f Mon Sep 17 00:00:00 2001 From: fengkx Date: Tue, 14 Dec 2021 14:04:33 +0800 Subject: [PATCH 1/3] feat: new hook useGetSet --- .../migrating-from-react-use.story.mdx | 2 +- src/index.ts | 1 + src/useGetSet/__docs__/example.stories.tsx | 12 ++++ src/useGetSet/__docs__/story.mdx | 38 ++++++++++++ src/useGetSet/__tests__/dom.ts | 62 +++++++++++++++++++ src/useGetSet/__tests__/ssr.ts | 62 +++++++++++++++++++ src/useGetSet/useGetSet.ts | 19 ++++++ 7 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/useGetSet/__docs__/example.stories.tsx create mode 100644 src/useGetSet/__docs__/story.mdx create mode 100644 src/useGetSet/__tests__/dom.ts create mode 100644 src/useGetSet/__tests__/ssr.ts create mode 100644 src/useGetSet/useGetSet.ts diff --git a/src/__docs__/migrating-from-react-use.story.mdx b/src/__docs__/migrating-from-react-use.story.mdx index b83449152..197d178e2 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 4cf2f65e3..028f767a0 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 000000000..5c8f89c84 --- /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 000000000..e22180664 --- /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 000000000..84fdeb7ff --- /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 000000000..107819c05 --- /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 000000000..406e82497 --- /dev/null +++ b/src/useGetSet/useGetSet.ts @@ -0,0 +1,19 @@ +import { useRef, useCallback } from 'react'; +import { IInitialState, INextState, resolveHookState } from '../util/resolveHookState'; +import { useRerender } from '../index'; + +export function useGetSet( + initialState: IInitialState +): [get: () => S, set: (nextState: INextState) => void] { + const rerender = useRerender(); + const ref = useRef(resolveHookState(initialState)); + const get = useCallback(() => ref.current, []); + const set = useCallback( + (nextState: INextState) => { + ref.current = resolveHookState(nextState, ref.current); + rerender(); + }, + [rerender] + ); + return [get, set]; +} From e311819b4862f2a51487e42273ee4470deaa74bf Mon Sep 17 00:00:00 2001 From: fengkx Date: Fri, 17 Dec 2021 21:21:56 +0800 Subject: [PATCH 2/3] feat: change impl --- src/useGetSet/useGetSet.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/useGetSet/useGetSet.ts b/src/useGetSet/useGetSet.ts index 406e82497..e198dc786 100644 --- a/src/useGetSet/useGetSet.ts +++ b/src/useGetSet/useGetSet.ts @@ -1,19 +1,13 @@ -import { useRef, useCallback } from 'react'; -import { IInitialState, INextState, resolveHookState } from '../util/resolveHookState'; -import { useRerender } from '../index'; +import { useCallback, Dispatch, SetStateAction } from 'react'; +import { IInitialState } from '../util/resolveHookState'; +import { useSafeState, useSyncedRef } from '../index'; export function useGetSet( initialState: IInitialState -): [get: () => S, set: (nextState: INextState) => void] { - const rerender = useRerender(); - const ref = useRef(resolveHookState(initialState)); - const get = useCallback(() => ref.current, []); - const set = useCallback( - (nextState: INextState) => { - ref.current = resolveHookState(nextState, ref.current); - rerender(); - }, - [rerender] - ); - return [get, set]; +): [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]; } From a05d0ae5295ea15b49405ad812f4ff45537ee5a7 Mon Sep 17 00:00:00 2001 From: fengkx Date: Fri, 17 Dec 2021 21:24:38 +0800 Subject: [PATCH 3/3] docs: README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 870e5cebd..edd93dcd2 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