Skip to content

Commit 02433d1

Browse files
feat: Support setState callback in useControlledState (#9041)
* feat: Support function setState callback in useControlledState * fixes * fix docs build --------- Co-authored-by: Robert Snow <[email protected]>
1 parent eb0006c commit 02433d1

File tree

6 files changed

+200
-70
lines changed

6 files changed

+200
-70
lines changed

packages/@react-stately/calendar/docs/useRangeCalendarState.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ keywords: [date, calendar, state]
3333

3434
## Interface
3535

36-
<ClassAPI links={docs.links} class={docs.links[docs.exports.useRangeCalendarState.return.id]} />
36+
<ClassAPI links={docs.links} class={docs.links[docs.exports.useRangeCalendarState.return.base.id]} />
3737

3838
## Example
3939

packages/@react-stately/calendar/src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,11 @@ export interface CalendarState extends CalendarStateBase {
106106
setValue(value: CalendarDate | null): void
107107
}
108108

109-
export interface RangeCalendarState extends CalendarStateBase {
109+
export interface RangeCalendarState<T extends DateValue = DateValue> extends CalendarStateBase {
110110
/** The currently selected date range. */
111-
readonly value: RangeValue<DateValue> | null,
111+
readonly value: RangeValue<T> | null,
112112
/** Sets the currently selected date range. */
113-
setValue(value: RangeValue<DateValue> | null): void,
113+
setValue(value: RangeValue<T> | null): void,
114114
/** Highlights the given date during selection, e.g. by hovering or dragging. */
115115
highlightDate(date: CalendarDate): void,
116116
/** The current anchor date that the user clicked on to begin range selection. */

packages/@react-stately/calendar/src/useRangeCalendarState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export interface RangeCalendarStateOptions<T extends DateValue = DateValue> exte
4545
* Provides state management for a range calendar component.
4646
* A range calendar displays one or more date grids and allows users to select a contiguous range of dates.
4747
*/
48-
export function useRangeCalendarState<T extends DateValue = DateValue>(props: RangeCalendarStateOptions<T>): RangeCalendarState {
48+
export function useRangeCalendarState<T extends DateValue = DateValue>(props: RangeCalendarStateOptions<T>): RangeCalendarState<T> {
4949
let {
5050
value: valueProp,
5151
defaultValue,

packages/@react-stately/checkbox/src/useCheckboxGroupState.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,12 @@ export function useCheckboxGroupState(props: CheckboxGroupProps = {}): CheckboxG
9898
if (props.isReadOnly || props.isDisabled) {
9999
return;
100100
}
101-
if (!selectedValues.includes(value)) {
102-
selectedValues = selectedValues.concat(value);
103-
setValue(selectedValues);
104-
}
101+
setValue(selectedValues => {
102+
if (!selectedValues.includes(value)) {
103+
return selectedValues.concat(value);
104+
}
105+
return selectedValues;
106+
});
105107
},
106108
removeValue(value) {
107109
if (props.isReadOnly || props.isDisabled) {

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

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

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

15-
export function useControlledState<T, C = T>(value: Exclude<T, undefined>, defaultValue: Exclude<T, undefined> | undefined, onChange?: (v: C, ...args: any[]) => void): [T, (value: T, ...args: any[]) => void];
16-
export function useControlledState<T, C = T>(value: Exclude<T, undefined> | undefined, defaultValue: Exclude<T, undefined>, onChange?: (v: C, ...args: any[]) => void): [T, (value: T, ...args: any[]) => void];
17-
export function useControlledState<T, C = T>(value: T, defaultValue: T, onChange?: (v: C, ...args: any[]) => void): [T, (value: T, ...args: any[]) => void] {
15+
// Use the earliest effect possible to reset the ref below.
16+
const useEarlyEffect: typeof React.useLayoutEffect = typeof document !== 'undefined'
17+
? React['useInsertionEffect'] ?? React.useLayoutEffect
18+
: () => {};
19+
20+
export function useControlledState<T, C = T>(value: Exclude<T, undefined>, defaultValue: Exclude<T, undefined> | undefined, onChange?: (v: C, ...args: any[]) => void): [T, (value: SetStateAction<T>, ...args: any[]) => void];
21+
export function useControlledState<T, C = T>(value: Exclude<T, undefined> | undefined, defaultValue: Exclude<T, undefined>, onChange?: (v: C, ...args: any[]) => void): [T, (value: SetStateAction<T>, ...args: any[]) => void];
22+
export function useControlledState<T, C = T>(value: T, defaultValue: T, onChange?: (v: C, ...args: any[]) => void): [T, (value: SetStateAction<T>, ...args: any[]) => void] {
23+
// Store the value in both state and a ref. The state value will only be used when uncontrolled.
24+
// The ref is used to track the most current value, which is passed to the function setState callback.
1825
let [stateValue, setStateValue] = useState(value || defaultValue);
26+
let valueRef = useRef(stateValue);
1927

2028
let isControlledRef = useRef(value !== undefined);
2129
let isControlled = value !== undefined;
@@ -27,49 +35,29 @@ export function useControlledState<T, C = T>(value: T, defaultValue: T, onChange
2735
isControlledRef.current = isControlled;
2836
}, [isControlled]);
2937

38+
// After each render, update the ref to the current value.
39+
// This ensures that the setState callback argument is reset.
40+
// Note: the effect should not have any dependencies so that controlled values always reset.
3041
let currentValue = isControlled ? value : stateValue;
31-
let setValue = useCallback((value, ...args) => {
32-
let onChangeCaller = (value, ...onChangeArgs) => {
33-
if (onChange) {
34-
if (!Object.is(currentValue, value)) {
35-
onChange(value, ...onChangeArgs);
36-
}
37-
}
38-
if (!isControlled) {
39-
// If uncontrolled, mutate the currentValue local variable so that
40-
// calling setState multiple times with the same value only emits onChange once.
41-
// We do not use a ref for this because we specifically _do_ want the value to
42-
// reset every render, and assigning to a ref in render breaks aborted suspended renders.
43-
// eslint-disable-next-line react-hooks/exhaustive-deps
44-
currentValue = value;
45-
}
46-
};
42+
useEarlyEffect(() => {
43+
valueRef.current = currentValue;
44+
});
45+
46+
let setValue = useCallback((value: SetStateAction<T>, ...args: any[]) => {
47+
// @ts-ignore - TS doesn't know that T cannot be a function.
48+
let newValue = typeof value === 'function' ? value(valueRef.current) : value;
49+
if (!Object.is(valueRef.current, newValue)) {
50+
// Update the ref so that the next setState callback has the most recent value.
51+
valueRef.current = newValue;
52+
53+
// Always trigger a setState, even when controlled, so that the layout effect above runs to reset the value.
54+
setStateValue(newValue);
4755

48-
if (typeof value === 'function') {
49-
if (process.env.NODE_ENV !== 'production') {
50-
console.warn('We can not support a function callback. See Github Issues for details https://github.com/adobe/react-spectrum/issues/2320');
51-
}
52-
// this supports functional updates https://reactjs.org/docs/hooks-reference.html#functional-updates
53-
// when someone using useControlledState calls setControlledState(myFunc)
54-
// this will call our useState setState with a function as well which invokes myFunc and calls onChange with the value from myFunc
55-
// if we're in an uncontrolled state, then we also return the value of myFunc which to setState looks as though it was just called with myFunc from the beginning
56-
// otherwise we just return the controlled value, which won't cause a rerender because React knows to bail out when the value is the same
57-
let updateFunction = (oldValue, ...functionArgs) => {
58-
let interceptedValue = value(isControlled ? currentValue : oldValue, ...functionArgs);
59-
onChangeCaller(interceptedValue, ...args);
60-
if (!isControlled) {
61-
return interceptedValue;
62-
}
63-
return oldValue;
64-
};
65-
setStateValue(updateFunction);
66-
} else {
67-
if (!isControlled) {
68-
setStateValue(value);
69-
}
70-
onChangeCaller(value, ...args);
56+
// Trigger onChange. Note that if setState is called multiple times in a single event,
57+
// onChange will be called for each one instead of only once.
58+
onChange?.(newValue, ...args);
7159
}
72-
}, [isControlled, currentValue, onChange]);
60+
}, [onChange]);
7361

7462
return [currentValue, setValue];
7563
}

0 commit comments

Comments
 (0)