diff --git a/package.json b/package.json index 592eb98f..fb810e97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rc-component/util", - "version": "1.2.2", + "version": "1.3.0", "description": "Common Utils For React Component", "keywords": [ "react", diff --git a/src/hooks/useControlledState.ts b/src/hooks/useControlledState.ts new file mode 100644 index 00000000..881144bd --- /dev/null +++ b/src/hooks/useControlledState.ts @@ -0,0 +1,34 @@ +import { useState } from 'react'; +import useLayoutEffect from './useLayoutEffect'; + +type Updater = (updater: T | ((origin: T) => T)) => void; + +/** + * Similar to `useState` but will use props value if provided. + * From React 18, we do not need safe `useState` since it will not throw for unmounted update. + * This hooks remove the `onChange` & `postState` logic since we only need basic merged state logic. + */ +export default function useControlledState( + defaultStateValue: T | (() => T), + value?: T, +): [T, Updater] { + const [innerValue, setInnerValue] = useState(defaultStateValue); + + const mergedValue = value !== undefined ? value : innerValue; + + useLayoutEffect( + mount => { + if (!mount) { + setInnerValue(value); + } + }, + [value], + ); + + return [ + // Value + mergedValue, + // Update function + setInnerValue, + ]; +} diff --git a/src/hooks/useMergedState.ts b/src/hooks/useMergedState.ts index 1e846b54..6da5d466 100644 --- a/src/hooks/useMergedState.ts +++ b/src/hooks/useMergedState.ts @@ -13,6 +13,7 @@ function hasValue(value: any) { } /** + * @deprecated Please use `useControlledState` instead if not need support < React 18. * Similar to `useState` but will use props value if provided. * Note that internal use rc-util `useState` hook. */ diff --git a/src/index.ts b/src/index.ts index d1e3834f..84097aa4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { default as useEvent } from './hooks/useEvent'; export { default as useMergedState } from './hooks/useMergedState'; +export { default as useControlledState } from './hooks/useControlledState'; export { supportNodeRef, supportRef, useComposeRef } from './ref'; export { default as get } from './utils/get'; export { default as set, merge } from './utils/set'; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 8f9ec6b9..b8154c66 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -8,6 +8,7 @@ import useMergedState from '../src/hooks/useMergedState'; import useMobile from '../src/hooks/useMobile'; import useState from '../src/hooks/useState'; import useSyncState from '../src/hooks/useSyncState'; +import useControlledState from '../src/hooks/useControlledState'; global.disableUseId = false; @@ -317,6 +318,163 @@ describe('hooks', () => { }); }); + describe('useControlledState', () => { + const FC: React.FC<{ + value?: string; + defaultValue?: string | (() => string); + }> = props => { + const { value, defaultValue } = props; + const [val, setVal] = useControlledState( + defaultValue ?? null, + value, + ); + return ( + <> + { + setVal(e.target.value); + }} + /> + {val} + + ); + }; + + it('still control of to undefined', () => { + const { container, rerender } = render(); + + expect(container.querySelector('input').value).toEqual('test'); + expect(container.querySelector('.txt').textContent).toEqual('test'); + + rerender(); + expect(container.querySelector('input').value).toEqual('test'); + expect(container.querySelector('.txt').textContent).toEqual(''); + }); + + describe('correct defaultValue', () => { + it('raw', () => { + const { container } = render(); + + expect(container.querySelector('input').value).toEqual('test'); + }); + + it('func', () => { + const { container } = render( 'bamboo'} />); + + expect(container.querySelector('input').value).toEqual('bamboo'); + }); + }); + + it('not rerender when setState as deps', () => { + let renderTimes = 0; + + const Test = () => { + const [val, setVal] = useControlledState(0); + + React.useEffect(() => { + renderTimes += 1; + expect(renderTimes < 10).toBeTruthy(); + + setVal(1); + }, [setVal]); + + return
{val}
; + }; + + const { container } = render(); + expect(container.firstChild.textContent).toEqual('1'); + }); + + it('React 18 should not reset to undefined', () => { + const Demo = () => { + const [val] = useControlledState(33, undefined); + + return
{val}
; + }; + + const { container } = render( + + + , + ); + + expect(container.querySelector('div').textContent).toEqual('33'); + }); + + it('uncontrolled to controlled', () => { + const Demo: React.FC> = ({ value }) => { + const [mergedValue, setMergedValue] = useControlledState( + () => 233, + value, + ); + + return ( + { + setMergedValue(v => v + 1); + setMergedValue(v => v + 1); + }} + onMouseEnter={() => { + setMergedValue(1); + }} + > + {mergedValue} + + ); + }; + + const { container, rerender } = render(); + expect(container.textContent).toEqual('233'); + + // Update value + rerender(); + expect(container.textContent).toEqual('1'); + + // Click update + rerender(); + fireEvent.mouseEnter(container.querySelector('span')); + fireEvent.click(container.querySelector('span')); + expect(container.textContent).toEqual('3'); + }); + + it('should alway use option value', () => { + const Test: React.FC> = ({ value }) => { + const [mergedValue, setMergedValue] = useControlledState( + undefined, + value, + ); + return ( + { + setMergedValue(12); + }} + > + {mergedValue} + + ); + }; + + const { container } = render(); + fireEvent.click(container.querySelector('span')); + + expect(container.textContent).toBe('1'); + }); + + it('render once', () => { + let count = 0; + + const Demo: React.FC = () => { + const [] = useControlledState(undefined); + count += 1; + return null; + }; + + render(); + expect(count).toBe(1); + }); + }); + describe('useLayoutEffect', () => { const FC: React.FC> = props => { const { defaultValue } = props;