Skip to content

Commit 8c3499c

Browse files
authored
Only handle form reset when defaultValue is used (#3240)
* add `useDefaultValue` hook This allows us to have a guaranteed `default value` that never changes unless the component re-mounts. Since the hook returns a stable value, we can safely include it in dependency arrays of certain hooks. Before this change, including this is in the dependency arrays it would cause a trigger or change of the hook when the `defaultValue` changes but we never want that. * do not handle `reset` when no `defaultValue` or `defaultChecked` was provided If a `defaultValue` is provided, then the reset will be handled and the `onChange` will be called with this value. If no `defaultValue` was provided, we won't handle the `reset`, otherwise we would call the `onChange` with `undefined` which is incorrect. * update changelog
1 parent c2754bc commit 8c3499c

File tree

7 files changed

+51
-14
lines changed

7 files changed

+51
-14
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Ensure page doesn't scroll down when pressing `Escape` to close the `Dialog` component ([#3218](https://github.com/tailwindlabs/headlessui/pull/3218))
1414
- Fix crash when toggling between `virtual` and non-virtual mode in `Combobox` component ([#3236](https://github.com/tailwindlabs/headlessui/pull/3236))
1515
- Ensure tabbing to a portalled `<PopoverPanel>` component moves focus inside (without using `<PortalGroup>`) ([#3239](https://github.com/tailwindlabs/headlessui/pull/3239))
16+
- Only handle form reset when `defaultValue` is used ([#3240](https://github.com/tailwindlabs/headlessui/pull/3240))
1617

1718
### Deprecated
1819

packages/@headlessui-react/src/components/checkbox/checkbox.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import React, {
1313
} from 'react'
1414
import { useActivePress } from '../../hooks/use-active-press'
1515
import { useControllable } from '../../hooks/use-controllable'
16+
import { useDefaultValue } from '../../hooks/use-default-value'
1617
import { useDisposables } from '../../hooks/use-disposables'
1718
import { useEvent } from '../../hooks/use-event'
1819
import { useId } from '../../hooks/use-id'
@@ -85,7 +86,7 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
8586
disabled = providedDisabled || false,
8687
autoFocus = false,
8788
checked: controlledChecked,
88-
defaultChecked = false,
89+
defaultChecked: _defaultChecked,
8990
onChange: controlledOnChange,
9091
name,
9192
value,
@@ -94,7 +95,12 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
9495
...theirProps
9596
} = props
9697

97-
let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)
98+
let defaultChecked = useDefaultValue(_defaultChecked)
99+
let [checked, onChange] = useControllable(
100+
controlledChecked,
101+
controlledOnChange,
102+
defaultChecked ?? false
103+
)
98104

99105
let labelledBy = useLabelledBy()
100106
let describedBy = useDescribedBy()
@@ -166,8 +172,9 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
166172
}, [checked, indeterminate, disabled, hover, focus, active, changing, autoFocus])
167173

168174
let reset = useCallback(() => {
175+
if (defaultChecked === undefined) return
169176
return onChange?.(defaultChecked)
170-
}, [onChange /* Explicitly ignoring `defaultChecked` */])
177+
}, [onChange, defaultChecked])
171178

172179
return (
173180
<>

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import React, {
2424
import { useActivePress } from '../../hooks/use-active-press'
2525
import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
2626
import { useControllable } from '../../hooks/use-controllable'
27+
import { useDefaultValue } from '../../hooks/use-default-value'
2728
import { useDisposables } from '../../hooks/use-disposables'
2829
import { useElementSize } from '../../hooks/use-element-size'
2930
import { useEvent } from '../../hooks/use-event'
@@ -635,7 +636,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
635636
let providedDisabled = useDisabled()
636637
let {
637638
value: controlledValue,
638-
defaultValue,
639+
defaultValue: _defaultValue,
639640
onChange: controlledOnChange,
640641
form,
641642
name,
@@ -651,6 +652,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
651652
nullable: _nullable,
652653
...theirProps
653654
} = props
655+
let defaultValue = useDefaultValue(_defaultValue)
654656
let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
655657
controlledValue,
656658
controlledOnChange,
@@ -887,8 +889,9 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
887889
let ourProps = ref === null ? {} : { ref }
888890

889891
let reset = useCallback(() => {
892+
if (defaultValue === undefined) return
890893
return theirOnChange?.(defaultValue)
891-
}, [theirOnChange /* Explicitly ignoring `defaultValue` */])
894+
}, [theirOnChange, defaultValue])
892895

893896
return (
894897
<LabelProvider

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useActivePress } from '../../hooks/use-active-press'
2424
import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
2525
import { useComputed } from '../../hooks/use-computed'
2626
import { useControllable } from '../../hooks/use-controllable'
27+
import { useDefaultValue } from '../../hooks/use-default-value'
2728
import { useDidElementMove } from '../../hooks/use-did-element-move'
2829
import { useDisposables } from '../../hooks/use-disposables'
2930
import { useElementSize } from '../../hooks/use-element-size'
@@ -481,7 +482,7 @@ function ListboxFn<
481482
let providedDisabled = useDisabled()
482483
let {
483484
value: controlledValue,
484-
defaultValue,
485+
defaultValue: _defaultValue,
485486
form,
486487
name,
487488
onChange: controlledOnChange,
@@ -493,9 +494,11 @@ function ListboxFn<
493494
__demoMode = false,
494495
...theirProps
495496
} = props
497+
496498
const orientation = horizontal ? 'horizontal' : 'vertical'
497499
let listboxRef = useSyncRefs(ref)
498500

501+
let defaultValue = useDefaultValue(_defaultValue)
499502
let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
500503
controlledValue,
501504
controlledOnChange,
@@ -660,8 +663,9 @@ function ListboxFn<
660663
let ourProps = { ref: listboxRef }
661664

662665
let reset = useCallback(() => {
666+
if (defaultValue === undefined) return
663667
return theirOnChange?.(defaultValue)
664-
}, [theirOnChange /* Explicitly ignoring `defaultValue` */])
668+
}, [theirOnChange, defaultValue])
665669

666670
return (
667671
<LabelProvider

packages/@headlessui-react/src/components/radio-group/radio-group.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import React, {
1717
} from 'react'
1818
import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
1919
import { useControllable } from '../../hooks/use-controllable'
20+
import { useDefaultValue } from '../../hooks/use-default-value'
2021
import { useEvent } from '../../hooks/use-event'
2122
import { useId } from '../../hooks/use-id'
2223
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -171,15 +172,14 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
171172
let {
172173
id = `headlessui-radiogroup-${internalId}`,
173174
value: controlledValue,
174-
defaultValue,
175175
form,
176176
name,
177177
onChange: controlledOnChange,
178178
by,
179179
disabled = providedDisabled || false,
180+
defaultValue: _defaultValue,
180181
...theirProps
181182
} = props
182-
183183
let compare = useByComparator(by)
184184
let [state, dispatch] = useReducer(stateReducer, { options: [] } as StateDefinition<TType>)
185185
let options = state.options as Option<TType>[]
@@ -188,6 +188,7 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
188188
let internalRadioGroupRef = useRef<HTMLElement | null>(null)
189189
let radioGroupRef = useSyncRefs(internalRadioGroupRef, ref)
190190

191+
let defaultValue = useDefaultValue(_defaultValue)
191192
let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue)
192193

193194
let firstOption = useMemo(
@@ -304,8 +305,9 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
304305
let slot = useMemo(() => ({ value }) satisfies RadioGroupRenderPropArg<TType>, [value])
305306

306307
let reset = useCallback(() => {
307-
return triggerChange(defaultValue!)
308-
}, [triggerChange /* Explicitly ignoring `defaultValue` */])
308+
if (defaultValue === undefined) return
309+
return triggerChange(defaultValue)
310+
}, [triggerChange, defaultValue])
309311

310312
return (
311313
<DescriptionProvider name="RadioGroup.Description">

packages/@headlessui-react/src/components/switch/switch.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import React, {
1717
} from 'react'
1818
import { useActivePress } from '../../hooks/use-active-press'
1919
import { useControllable } from '../../hooks/use-controllable'
20+
import { useDefaultValue } from '../../hooks/use-default-value'
2021
import { useDisposables } from '../../hooks/use-disposables'
2122
import { useEvent } from '../../hooks/use-event'
2223
import { useId } from '../../hooks/use-id'
@@ -146,7 +147,7 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
146147
id = providedId || `headlessui-switch-${internalId}`,
147148
disabled = providedDisabled || false,
148149
checked: controlledChecked,
149-
defaultChecked = false,
150+
defaultChecked: _defaultChecked,
150151
onChange: controlledOnChange,
151152
name,
152153
value,
@@ -162,7 +163,12 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
162163
groupContext === null ? null : groupContext.setSwitch
163164
)
164165

165-
let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)
166+
let defaultChecked = useDefaultValue(_defaultChecked)
167+
let [checked, onChange] = useControllable(
168+
controlledChecked,
169+
controlledOnChange,
170+
defaultChecked ?? false
171+
)
166172

167173
let d = useDisposables()
168174
let [changing, setChanging] = useState(false)
@@ -232,8 +238,9 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
232238
)
233239

234240
let reset = useCallback(() => {
241+
if (defaultChecked === undefined) return
235242
return onChange?.(defaultChecked)
236-
}, [onChange /* Explicitly ignoring `defaultChecked` */])
243+
}, [onChange, defaultChecked])
237244

238245
return (
239246
<>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useState } from 'react'
2+
3+
/**
4+
* Returns a stable value that never changes unless the component is re-mounted.
5+
*
6+
* This ensures that we can use this value in a dependency array without causing
7+
* unnecessary re-renders (because while the incoming `value` can change, the
8+
* returned `defaultValue` won't change).
9+
*/
10+
export function useDefaultValue<T>(value: T) {
11+
let [defaultValue] = useState(value)
12+
return defaultValue
13+
}

0 commit comments

Comments
 (0)