From eca4dd1a3b90c4246f4444708e599e15739cb440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 2 Sep 2025 15:56:13 +0800 Subject: [PATCH 1/2] chore: comment --- src/hooks/useMergedState.ts | 1 + src/hooks/usePropState.ts | 34 ++++++++ tests/hooks.test.tsx | 155 ++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 src/hooks/usePropState.ts diff --git a/src/hooks/useMergedState.ts b/src/hooks/useMergedState.ts index 1e846b54..37fb1f24 100644 --- a/src/hooks/useMergedState.ts +++ b/src/hooks/useMergedState.ts @@ -13,6 +13,7 @@ function hasValue(value: any) { } /** + * @deprecated Please use `usePropState` 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/hooks/usePropState.ts b/src/hooks/usePropState.ts new file mode 100644 index 00000000..77b72690 --- /dev/null +++ b/src/hooks/usePropState.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 usePropState( + 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/tests/hooks.test.tsx b/tests/hooks.test.tsx index 2d05e8c7..02db410b 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 usePropState from '../src/hooks/usePropState'; global.disableUseId = false; @@ -317,6 +318,160 @@ describe('hooks', () => { }); }); + describe('usePropState', () => { + const FC: React.FC<{ + value?: string; + defaultValue?: string | (() => string); + }> = props => { + const { value, defaultValue } = props; + const [val, setVal] = usePropState(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] = usePropState(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] = usePropState(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] = usePropState( + () => 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] = usePropState( + 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 [] = usePropState(undefined); + count += 1; + return null; + }; + + render(); + expect(count).toBe(1); + }); + }); + describe('useLayoutEffect', () => { const FC: React.FC> = props => { const { defaultValue } = props; From 262823e3be4f12a71ef0078b2aa9d52ec60e8ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 2 Sep 2025 16:15:49 +0800 Subject: [PATCH 2/2] chore: rename --- ...{usePropState.ts => useControlledState.ts} | 2 +- src/hooks/useMergedState.ts | 2 +- tests/hooks.test.tsx | 19 +++++++++++-------- 3 files changed, 13 insertions(+), 10 deletions(-) rename src/hooks/{usePropState.ts => useControlledState.ts} (94%) diff --git a/src/hooks/usePropState.ts b/src/hooks/useControlledState.ts similarity index 94% rename from src/hooks/usePropState.ts rename to src/hooks/useControlledState.ts index 77b72690..881144bd 100644 --- a/src/hooks/usePropState.ts +++ b/src/hooks/useControlledState.ts @@ -8,7 +8,7 @@ type Updater = (updater: T | ((origin: T) => T)) => void; * 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 usePropState( +export default function useControlledState( defaultStateValue: T | (() => T), value?: T, ): [T, Updater] { diff --git a/src/hooks/useMergedState.ts b/src/hooks/useMergedState.ts index 37fb1f24..6da5d466 100644 --- a/src/hooks/useMergedState.ts +++ b/src/hooks/useMergedState.ts @@ -13,7 +13,7 @@ function hasValue(value: any) { } /** - * @deprecated Please use `usePropState` instead if not need support < React 18. + * @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/tests/hooks.test.tsx b/tests/hooks.test.tsx index 02db410b..bde751f6 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -8,7 +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 usePropState from '../src/hooks/usePropState'; +import useControlledState from '../src/hooks/useControlledState'; global.disableUseId = false; @@ -318,13 +318,16 @@ describe('hooks', () => { }); }); - describe('usePropState', () => { + describe('useControlledState', () => { const FC: React.FC<{ value?: string; defaultValue?: string | (() => string); }> = props => { const { value, defaultValue } = props; - const [val, setVal] = usePropState(defaultValue ?? null, value); + const [val, setVal] = useControlledState( + defaultValue ?? null, + value, + ); return ( <> { let renderTimes = 0; const Test = () => { - const [val, setVal] = usePropState(0); + const [val, setVal] = useControlledState(0); React.useEffect(() => { renderTimes += 1; @@ -385,7 +388,7 @@ describe('hooks', () => { it('React 18 should not reset to undefined', () => { const Demo = () => { - const [val] = usePropState(33, undefined); + const [val] = useControlledState(33, undefined); return
{val}
; }; @@ -401,7 +404,7 @@ describe('hooks', () => { it('uncontrolled to controlled', () => { const Demo: React.FC> = ({ value }) => { - const [mergedValue, setMergedValue] = usePropState( + const [mergedValue, setMergedValue] = useControlledState( () => 233, value, ); @@ -437,7 +440,7 @@ describe('hooks', () => { it('should alway use option value', () => { const Test: React.FC> = ({ value }) => { - const [mergedValue, setMergedValue] = usePropState( + const [mergedValue, setMergedValue] = useControlledState( undefined, value, ); @@ -462,7 +465,7 @@ describe('hooks', () => { let count = 0; const Demo: React.FC = () => { - const [] = usePropState(undefined); + const [] = useControlledState(undefined); count += 1; return null; };