Skip to content

Commit 46832b7

Browse files
authored
feat: Support usePropState (#673)
* chore: comment * chore: rename
1 parent 62e1415 commit 46832b7

File tree

3 files changed

+193
-0
lines changed

3 files changed

+193
-0
lines changed

src/hooks/useControlledState.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useState } from 'react';
2+
import useLayoutEffect from './useLayoutEffect';
3+
4+
type Updater<T> = (updater: T | ((origin: T) => T)) => void;
5+
6+
/**
7+
* Similar to `useState` but will use props value if provided.
8+
* From React 18, we do not need safe `useState` since it will not throw for unmounted update.
9+
* This hooks remove the `onChange` & `postState` logic since we only need basic merged state logic.
10+
*/
11+
export default function useControlledState<T>(
12+
defaultStateValue: T | (() => T),
13+
value?: T,
14+
): [T, Updater<T>] {
15+
const [innerValue, setInnerValue] = useState<T>(defaultStateValue);
16+
17+
const mergedValue = value !== undefined ? value : innerValue;
18+
19+
useLayoutEffect(
20+
mount => {
21+
if (!mount) {
22+
setInnerValue(value);
23+
}
24+
},
25+
[value],
26+
);
27+
28+
return [
29+
// Value
30+
mergedValue,
31+
// Update function
32+
setInnerValue,
33+
];
34+
}

src/hooks/useMergedState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function hasValue(value: any) {
1313
}
1414

1515
/**
16+
* @deprecated Please use `useControlledState` instead if not need support < React 18.
1617
* Similar to `useState` but will use props value if provided.
1718
* Note that internal use rc-util `useState` hook.
1819
*/

tests/hooks.test.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import useMergedState from '../src/hooks/useMergedState';
88
import useMobile from '../src/hooks/useMobile';
99
import useState from '../src/hooks/useState';
1010
import useSyncState from '../src/hooks/useSyncState';
11+
import useControlledState from '../src/hooks/useControlledState';
1112

1213
global.disableUseId = false;
1314

@@ -317,6 +318,163 @@ describe('hooks', () => {
317318
});
318319
});
319320

321+
describe('useControlledState', () => {
322+
const FC: React.FC<{
323+
value?: string;
324+
defaultValue?: string | (() => string);
325+
}> = props => {
326+
const { value, defaultValue } = props;
327+
const [val, setVal] = useControlledState<string>(
328+
defaultValue ?? null,
329+
value,
330+
);
331+
return (
332+
<>
333+
<input
334+
value={val}
335+
onChange={e => {
336+
setVal(e.target.value);
337+
}}
338+
/>
339+
<span className="txt">{val}</span>
340+
</>
341+
);
342+
};
343+
344+
it('still control of to undefined', () => {
345+
const { container, rerender } = render(<FC value="test" />);
346+
347+
expect(container.querySelector('input').value).toEqual('test');
348+
expect(container.querySelector('.txt').textContent).toEqual('test');
349+
350+
rerender(<FC value={undefined} />);
351+
expect(container.querySelector('input').value).toEqual('test');
352+
expect(container.querySelector('.txt').textContent).toEqual('');
353+
});
354+
355+
describe('correct defaultValue', () => {
356+
it('raw', () => {
357+
const { container } = render(<FC defaultValue="test" />);
358+
359+
expect(container.querySelector('input').value).toEqual('test');
360+
});
361+
362+
it('func', () => {
363+
const { container } = render(<FC defaultValue={() => 'bamboo'} />);
364+
365+
expect(container.querySelector('input').value).toEqual('bamboo');
366+
});
367+
});
368+
369+
it('not rerender when setState as deps', () => {
370+
let renderTimes = 0;
371+
372+
const Test = () => {
373+
const [val, setVal] = useControlledState(0);
374+
375+
React.useEffect(() => {
376+
renderTimes += 1;
377+
expect(renderTimes < 10).toBeTruthy();
378+
379+
setVal(1);
380+
}, [setVal]);
381+
382+
return <div>{val}</div>;
383+
};
384+
385+
const { container } = render(<Test />);
386+
expect(container.firstChild.textContent).toEqual('1');
387+
});
388+
389+
it('React 18 should not reset to undefined', () => {
390+
const Demo = () => {
391+
const [val] = useControlledState(33, undefined);
392+
393+
return <div>{val}</div>;
394+
};
395+
396+
const { container } = render(
397+
<React.StrictMode>
398+
<Demo />
399+
</React.StrictMode>,
400+
);
401+
402+
expect(container.querySelector('div').textContent).toEqual('33');
403+
});
404+
405+
it('uncontrolled to controlled', () => {
406+
const Demo: React.FC<Readonly<{ value?: number }>> = ({ value }) => {
407+
const [mergedValue, setMergedValue] = useControlledState<number>(
408+
() => 233,
409+
value,
410+
);
411+
412+
return (
413+
<span
414+
onClick={() => {
415+
setMergedValue(v => v + 1);
416+
setMergedValue(v => v + 1);
417+
}}
418+
onMouseEnter={() => {
419+
setMergedValue(1);
420+
}}
421+
>
422+
{mergedValue}
423+
</span>
424+
);
425+
};
426+
427+
const { container, rerender } = render(<Demo />);
428+
expect(container.textContent).toEqual('233');
429+
430+
// Update value
431+
rerender(<Demo value={1} />);
432+
expect(container.textContent).toEqual('1');
433+
434+
// Click update
435+
rerender(<Demo value={undefined} />);
436+
fireEvent.mouseEnter(container.querySelector('span'));
437+
fireEvent.click(container.querySelector('span'));
438+
expect(container.textContent).toEqual('3');
439+
});
440+
441+
it('should alway use option value', () => {
442+
const Test: React.FC<Readonly<{ value?: number }>> = ({ value }) => {
443+
const [mergedValue, setMergedValue] = useControlledState<number>(
444+
undefined,
445+
value,
446+
);
447+
return (
448+
<span
449+
onClick={() => {
450+
setMergedValue(12);
451+
}}
452+
>
453+
{mergedValue}
454+
</span>
455+
);
456+
};
457+
458+
const { container } = render(<Test value={1} />);
459+
fireEvent.click(container.querySelector('span'));
460+
461+
expect(container.textContent).toBe('1');
462+
});
463+
464+
it('render once', () => {
465+
let count = 0;
466+
467+
const Demo: React.FC = () => {
468+
const [] = useControlledState(undefined);
469+
count += 1;
470+
return null;
471+
};
472+
473+
render(<Demo />);
474+
expect(count).toBe(1);
475+
});
476+
});
477+
320478
describe('useLayoutEffect', () => {
321479
const FC: React.FC<Readonly<{ defaultValue?: string }>> = props => {
322480
const { defaultValue } = props;

0 commit comments

Comments
 (0)