From e95497b778bd3005b5d30d255a182ba95be3bafe Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Mon, 8 Jun 2026 14:29:56 -0700 Subject: [PATCH 1/3] listen to form reset event for all form controls --- .changeset/form-control-reset.md | 9 +++++++++ packages/react/radio-group/src/radio-group.tsx | 14 +++++++++++++- packages/react/select/src/select.tsx | 10 ++++++++++ packages/react/slider/src/slider.tsx | 14 +++++++++++++- packages/react/switch/src/switch.tsx | 11 +++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 .changeset/form-control-reset.md diff --git a/.changeset/form-control-reset.md b/.changeset/form-control-reset.md new file mode 100644 index 0000000000..8bc681897f --- /dev/null +++ b/.changeset/form-control-reset.md @@ -0,0 +1,9 @@ +--- +"@radix-ui/react-radio-group": patch +"@radix-ui/react-slider": patch +"@radix-ui/react-select": patch +"@radix-ui/react-switch": patch +"radix-ui": patch +--- + +All form control components now listen to their associated form's `reset` event and restore their initial value. This affects `RadioGroup`, `Slider`, `Select`, and `Switch`. diff --git a/packages/react/radio-group/src/radio-group.tsx b/packages/react/radio-group/src/radio-group.tsx index feea88c272..7407e06140 100644 --- a/packages/react/radio-group/src/radio-group.tsx +++ b/packages/react/radio-group/src/radio-group.tsx @@ -83,6 +83,18 @@ const RadioGroup = React.forwardRef( onChange: onValueChange as (value: string | null) => void, caller: RADIO_GROUP_NAME, }); + const [control, setControl] = React.useState(null); + const composedRefs = useComposedRefs(forwardedRef, setControl); + + const initialValueRef = React.useRef(value); + React.useEffect(() => { + const form = control?.closest('form'); + if (form) { + const reset = () => setValue(initialValueRef.current); + form.addEventListener('reset', reset); + return () => form.removeEventListener('reset', reset); + } + }, [control, setValue]); return ( ( data-disabled={disabled ? '' : undefined} dir={direction} {...groupProps} - ref={forwardedRef} + ref={composedRefs} /> diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index 14cd1f4001..bcad781139 100644 --- a/packages/react/select/src/select.tsx +++ b/packages/react/select/src/select.tsx @@ -185,6 +185,16 @@ function SelectProvider(props: ScopedProps) { }); const triggerPointerDownPosRef = React.useRef<{ x: number; y: number } | null>(null); + const initialValueRef = React.useRef(value); + React.useEffect(() => { + const associatedForm = trigger?.form; + if (associatedForm) { + const reset = () => setValue(initialValueRef.current); + associatedForm.addEventListener('reset', reset); + return () => associatedForm.removeEventListener('reset', reset); + } + }, [trigger, setValue]); + // We set this to true by default so that events bubble to forms without JS (SSR) const isFormControl = trigger ? !!form || !!trigger.closest('form') : true; const [nativeOptionsSet, setNativeOptionsSet] = React.useState(new Set()); diff --git a/packages/react/slider/src/slider.tsx b/packages/react/slider/src/slider.tsx index ecdaf3738b..f33a73ce8e 100644 --- a/packages/react/slider/src/slider.tsx +++ b/packages/react/slider/src/slider.tsx @@ -112,6 +112,8 @@ const Slider = React.forwardRef( const isKeyboardInteractionRef = React.useRef(false); const isHorizontal = orientation === 'horizontal'; const SliderOrientation = isHorizontal ? SliderHorizontal : SliderVertical; + const [control, setControl] = React.useState(null); + const composedRefs = useComposedRefs(forwardedRef, setControl); const [values = [], setValues] = useControllableState({ prop: value, @@ -128,6 +130,16 @@ const Slider = React.forwardRef( }); const valuesBeforeSlideStartRef = React.useRef(values); + const initialValuesRef = React.useRef(values); + React.useEffect(() => { + const associatedForm = control?.closest('form'); + if (associatedForm) { + const reset = () => setValues(initialValuesRef.current); + associatedForm.addEventListener('reset', reset); + return () => associatedForm.removeEventListener('reset', reset); + } + }, [control, setValues]); + function handleSlideStart(value: number) { const closestIndex = getClosestValueIndex(values, value); updateValues(value, closestIndex); @@ -181,7 +193,7 @@ const Slider = React.forwardRef( aria-disabled={disabled} data-disabled={disabled ? '' : undefined} {...sliderProps} - ref={forwardedRef} + ref={composedRefs} onPointerDown={composeEventHandlers(sliderProps.onPointerDown, () => { if (!disabled) { valuesBeforeSlideStartRef.current = values; diff --git a/packages/react/switch/src/switch.tsx b/packages/react/switch/src/switch.tsx index 90f9b1ef48..668e540c3f 100644 --- a/packages/react/switch/src/switch.tsx +++ b/packages/react/switch/src/switch.tsx @@ -119,6 +119,7 @@ interface SwitchTriggerProps extends Omit< const SwitchTrigger = React.forwardRef( ({ __scopeSwitch, onClick, ...switchProps }: ScopedProps, forwardedRef) => { const { + control, value, disabled, checked, @@ -131,6 +132,16 @@ const SwitchTrigger = React.forwardRef( } = useSwitchContext(TRIGGER_NAME, __scopeSwitch); const composedRefs = useComposedRefs(forwardedRef, setControl); + const initialCheckedStateRef = React.useRef(checked); + React.useEffect(() => { + const form = control?.form; + if (form) { + const reset = () => setChecked(initialCheckedStateRef.current); + form.addEventListener('reset', reset); + return () => form.removeEventListener('reset', reset); + } + }, [control, setChecked]); + return ( Date: Mon, 8 Jun 2026 14:46:13 -0700 Subject: [PATCH 2/3] add stories --- .../storybook/stories/radio-group.stories.tsx | 49 +++++++++++ apps/storybook/stories/select.stories.tsx | 88 +++++++++++++++++++ apps/storybook/stories/slider.stories.tsx | 47 ++++++++++ apps/storybook/stories/switch.stories.tsx | 45 ++++++++++ 4 files changed, 229 insertions(+) diff --git a/apps/storybook/stories/radio-group.stories.tsx b/apps/storybook/stories/radio-group.stories.tsx index 9e94df5522..e03206b94a 100644 --- a/apps/storybook/stories/radio-group.stories.tsx +++ b/apps/storybook/stories/radio-group.stories.tsx @@ -90,6 +90,55 @@ export const PartsWithinForm = () => { ); }; +export const WithinFormReset = () => { + const [controlled, setControlled] = React.useState('1'); + + return ( +
event.preventDefault()}> +

+ Change the selection, then press Reset. Each group returns to its initial + value (the uncontrolled group via its defaultValue, the controlled group via + its initial value state). +

+ +
+ Uncontrolled (defaultValue) + + {['1', '2', '3'].map((value) => ( + + + + ))} + +
+ +
+
+ +
+ Controlled value: {controlled} + + {['1', '2', '3'].map((value) => ( + + + + ))} + +
+ +
+
+ + +
+ ); +}; + export const LegacyStyled = () => (