Skip to content

Commit 3d374c5

Browse files
fix: refactor useControlledState to address state-outdated issues (#9331)
* fix: refactor useControlledState to address state-outdated issues * Reduce fix to only required changes --------- Co-authored-by: Devon Govett <[email protected]>
1 parent 6bc588b commit 3d374c5

File tree

2 files changed

+15
-2
lines changed

2 files changed

+15
-2
lines changed

packages/@react-stately/utils/src/useControlledState.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import React, {SetStateAction, useCallback, useEffect, useRef, useState} from 'react';
13+
import React, {SetStateAction, useCallback, useEffect, useReducer, useRef, useState} from 'react';
1414

1515
// Use the earliest effect possible to reset the ref below.
1616
const useEarlyEffect: typeof React.useLayoutEffect = typeof document !== 'undefined'
@@ -43,16 +43,19 @@ export function useControlledState<T, C = T>(value: T, defaultValue: T, onChange
4343
valueRef.current = currentValue;
4444
});
4545

46+
let [, forceUpdate] = useReducer(() => ({}), {});
4647
let setValue = useCallback((value: SetStateAction<T>, ...args: any[]) => {
4748
// @ts-ignore - TS doesn't know that T cannot be a function.
4849
let newValue = typeof value === 'function' ? value(valueRef.current) : value;
4950
if (!Object.is(valueRef.current, newValue)) {
5051
// Update the ref so that the next setState callback has the most recent value.
5152
valueRef.current = newValue;
5253

53-
// Always trigger a setState, even when controlled, so that the layout effect above runs to reset the value.
5454
setStateValue(newValue);
5555

56+
// Always trigger a re-render, even when controlled, so that the layout effect above runs to reset the value.
57+
forceUpdate();
58+
5659
// Trigger onChange. Note that if setState is called multiple times in a single event,
5760
// onChange will be called for each one instead of only once.
5861
onChange?.(newValue, ...args);

packages/@react-stately/utils/test/useControlledState.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,16 @@ describe('useControlledState tests', function () {
212212

213213
onChangeSpy.mockClear();
214214

215+
act(() => setValue((prevValue) => {
216+
expect(prevValue).toBe('updated');
217+
return 'newValue';
218+
}));
219+
[value, setValue] = result.current;
220+
expect(value).toBe('updated');
221+
expect(onChangeSpy).toHaveBeenLastCalledWith('newValue');
222+
223+
onChangeSpy.mockClear();
224+
215225
act(() => setValue((prevValue) => {
216226
expect(prevValue).toBe('updated');
217227
return 'updated';

0 commit comments

Comments
 (0)