Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/hooks/useControlledState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useState } from 'react';
import useLayoutEffect from './useLayoutEffect';

type Updater<T> = (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<T>(
defaultStateValue: T | (() => T),
value?: T,
): [T, Updater<T>] {
const [innerValue, setInnerValue] = useState<T>(defaultStateValue);
Copy link

Copilot AI Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When value is undefined and defaultStateValue is a function, the function will be called on every render instead of only on initialization. This could cause performance issues and unexpected behavior. Consider using a lazy initial state pattern or memoization.

Suggested change
const [innerValue, setInnerValue] = useState<T>(defaultStateValue);
const [innerValue, setInnerValue] = useState<T>(
typeof defaultStateValue === 'function'
// @ts-ignore
? () => (defaultStateValue as () => T)()
: () => defaultStateValue
);
Copilot uses AI. Check for mistakes.

const mergedValue = value !== undefined ? value : innerValue;

useLayoutEffect(
mount => {
if (!mount) {
setInnerValue(value);
}
},
[value],
);

return [
// Value
mergedValue,
// Update function
setInnerValue,
];
}
1 change: 1 addition & 0 deletions src/hooks/useMergedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
158 changes: 158 additions & 0 deletions tests/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string>(
defaultValue ?? null,
value,
);
return (
<>
<input
value={val}
onChange={e => {
setVal(e.target.value);
}}
/>
<span className="txt">{val}</span>
</>
);
};

it('still control of to undefined', () => {
const { container, rerender } = render(<FC value="test" />);

expect(container.querySelector('input').value).toEqual('test');
expect(container.querySelector('.txt').textContent).toEqual('test');

rerender(<FC value={undefined} />);
expect(container.querySelector('input').value).toEqual('test');
expect(container.querySelector('.txt').textContent).toEqual('');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这个断言似乎不正确。val 同时用于 inputvaluespan 的内容。如果 input.value 期望是 'test',那么 spantextContent 也应该是 'test'。在 usePropState 修复为保留值的行为后,val 会是 'test'。这个断言 toEqual('') 可能是从 useMergedState 的测试中错误地复制过来的,建议将其修改为 toEqual('test') 以反映正确的预期行为。

Suggested change
expect(container.querySelector('.txt').textContent).toEqual('');
expect(container.querySelector('.txt').textContent).toEqual('test');
Copy link

Copilot AI Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion expects an empty string, but based on line 350 the input value remains 'test' when value becomes undefined. The text content should likely be 'test' to match the actual behavior, or there's a bug in the hook implementation.

Suggested change
expect(container.querySelector('.txt').textContent).toEqual('');
expect(container.querySelector('.txt').textContent).toEqual('test');
Copilot uses AI. Check for mistakes.
});

describe('correct defaultValue', () => {
it('raw', () => {
const { container } = render(<FC defaultValue="test" />);

expect(container.querySelector('input').value).toEqual('test');
});

it('func', () => {
const { container } = render(<FC defaultValue={() => '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 <div>{val}</div>;
};

const { container } = render(<Test />);
expect(container.firstChild.textContent).toEqual('1');
});

it('React 18 should not reset to undefined', () => {
const Demo = () => {
const [val] = useControlledState(33, undefined);

return <div>{val}</div>;
};

const { container } = render(
<React.StrictMode>
<Demo />
</React.StrictMode>,
);

expect(container.querySelector('div').textContent).toEqual('33');
});

it('uncontrolled to controlled', () => {
const Demo: React.FC<Readonly<{ value?: number }>> = ({ value }) => {
const [mergedValue, setMergedValue] = useControlledState<number>(
() => 233,
value,
);

return (
<span
onClick={() => {
setMergedValue(v => v + 1);
setMergedValue(v => v + 1);
}}
onMouseEnter={() => {
setMergedValue(1);
}}
>
{mergedValue}
</span>
);
};

const { container, rerender } = render(<Demo />);
expect(container.textContent).toEqual('233');

// Update value
rerender(<Demo value={1} />);
expect(container.textContent).toEqual('1');

// Click update
rerender(<Demo value={undefined} />);
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<Readonly<{ value?: number }>> = ({ value }) => {
const [mergedValue, setMergedValue] = useControlledState<number>(
undefined,
value,
);
return (
<span
onClick={() => {
setMergedValue(12);
}}
>
{mergedValue}
</span>
);
};

const { container } = render(<Test value={1} />);
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(<Demo />);
expect(count).toBe(1);
});
Comment on lines +468 to +475
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

修复 Biome 报错:Unexpected empty array pattern

空数组解构会触发 lint/correctness/noEmptyPattern。这里无需接收返回值,直接调用即可。

- const [] = useControlledState(undefined); + useControlledState(undefined);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [] = useControlledState(undefined);
count += 1;
return null;
};
render(<Demo />);
expect(count).toBe(1);
});
useControlledState(undefined);
count += 1;
return null;
};
render(<Demo />);
expect(count).toBe(1);
});
🧰 Tools
🪛 Biome (2.1.2)

[error] 468-468: Unexpected empty array pattern.

(lint/correctness/noEmptyPattern)

🤖 Prompt for AI Agents
In tests/hooks.test.tsx around lines 468 to 475, the line using an empty array destructuring "const [] = useControlledState(undefined);" triggers Biome lint rule noEmptyPattern; remove the empty destructuring and simply call useControlledState(undefined) directly (i.e., replace the const [] = ... with a plain call), so the hook is invoked without assigning its return value. 
});

describe('useLayoutEffect', () => {
const FC: React.FC<Readonly<{ defaultValue?: string }>> = props => {
const { defaultValue } = props;
Expand Down
Loading