Skip to content

Commit 987bfa3

Browse files
authored
Ensure form state is up to date when using uncontrolled components (#3790)
This PR fixes an issue where uncontrolled `Switch` or `Checkbox` components' form state aren't always up to date when calling the `onChange` handler. This is because the `onChange` handler is called at the same time the internal state is updated. That means that if you submit the nearest form as part of the `onChange` handler that the form state is not up to date yet. We fix this by calling `flushSync()` before we call the `onChange` handler when dealing with an uncontrolled component. ## Test plan Setup a small reproduction with both a controlled and uncontrolled checkbox <details> <summary>Reproduction</summary> ```tsx import { Switch } from '@headlessui/react' import { useRef, useState } from 'react' import { flushSync } from 'react-dom' export default function App() { let formRef = useRef<HTMLFormElement>(null) let [enabled, setEnabled] = useState(true) let [submisisons, setSubmissions] = useState<Array<Record<string, string>>>([]) return ( <div> <form className="p-8" ref={formRef} onSubmit={(e) => { e.preventDefault() let form = Object.fromEntries(new FormData(e.currentTarget).entries()) as Record< string, string > setSubmissions((s) => s.concat([form])) }} > <div className="flex flex-col gap-1"> <div className="flex items-center gap-2"> <Switch name="my-uncontrolled-switch" className="data-checked:bg-blue-500 aspect-square rounded border bg-white p-2" defaultChecked={true} onChange={() => { formRef.current?.requestSubmit() }} /> <div>Uncontrolled switch</div> </div> <div className="flex items-center gap-2"> <Switch name="my-controlled-switch" className="data-checked:bg-blue-500 aspect-square rounded border bg-white p-2" checked={enabled} defaultChecked={true} onChange={(v) => { // If you are controlling state yourself, then the `form` fields are // based on the incoming value, so in this case you have to call the // flushSync() yourself. flushSync(() => setEnabled(v)) formRef.current?.requestSubmit() }} /> <div>Controlled switch</div> </div> <button type="submit" className="mt-4 w-56 border"> Submit </button> </div> <hr className="my-8" /> <h3>Form submisisons:</h3> <pre>{JSON.stringify(submisisons.toReversed(), null, 2)}</pre> </form> </div> ) } ``` </details> Before: Notice that the moment I click the `uncontrolled` checkbox, the form is not up to date yet. Pressing submit again shows the correct value even though visually nothing changed anymore. https://github.com/user-attachments/assets/600eb3c2-9c56-40a7-900d-5694f391eced After: Here the form state is always up to date when submitting as part of the `onChange` handler. https://github.com/user-attachments/assets/640c9ed6-69d2-4d5b-acfc-bce7d0be8828 Fixes: #3760
1 parent 030773c commit 987bfa3

File tree

2 files changed

+7
-1
lines changed

2 files changed

+7
-1
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Ensure `--button-width` and `--input-width` have the latest value ([#3786](https://github.com/tailwindlabs/headlessui/pull/3786))
1818
- Fix 'Invalid prop `data-headlessui-state` supplied to `React.Fragment`' warning ([#3788](https://github.com/tailwindlabs/headlessui/pull/3788))
1919
- Ensure `element` in `ref` callback is always connected when rendering in a `Portal` ([#3789](https://github.com/tailwindlabs/headlessui/pull/3789))
20+
- Ensure form state is up to date when using uncontrolled components ([#3790](https://github.com/tailwindlabs/headlessui/pull/3790))
2021

2122
## [2.2.7] - 2025-07-30
2223

packages/@headlessui-react/src/hooks/use-controllable.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useRef, useState } from 'react'
2+
import { flushSync } from 'react-dom'
23
import { useEvent } from './use-event'
34

45
export function useControllable<T>(
@@ -33,7 +34,11 @@ export function useControllable<T>(
3334
if (isControlled) {
3435
return onChange?.(value)
3536
} else {
36-
setInternalValue(value)
37+
// Ensure internal state is up to date with the value, before calling
38+
// onChange. This allows you to submit forms as part of the `onChange`
39+
// and gives enough time to update the form field value(s).
40+
flushSync(() => setInternalValue(value))
41+
3742
return onChange?.(value)
3843
}
3944
}),

0 commit comments

Comments
 (0)