Skip to content

Commit

Permalink
implement uncontrolled form components
Browse files Browse the repository at this point in the history
A few versions ago we introduced compatibility with the native `form`
element. This means that behind the scenes we render hidden inputs that
are kept in sync which allows you to submit your normal form and get
data via `new FormData(event.currentTarget)`.

Before this change every form related component (Switch, RadioGroup,
Listbox and Combobox) always had to be passed a `value` and an
`onChange` regardless of this change.

This change will allow you to not even use the `value` and the
`onChange` at all and keep it completely uncontrolled.

This has some changes:

- `value` is made optional
- `onChange` is made optional (but will still be called if passed
  regardless of being controlled or uncontrolled)
- `defaultValue` got added so that you can still pre-fill your values
  with known values.
- `value` render prop got exposed so that you can still use this while
  rendering.

This should also make it completely compatible with tools like Remix
without wiring up your own state.
  • Loading branch information
RobinMalfait committed Jul 15, 2022
1 parent 6d13e79 commit bbc7b6d
Show file tree
Hide file tree
Showing 5 changed files with 88 additions and 26 deletions.
21 changes: 13 additions & 8 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'

import { Keys } from '../keyboard'
import { useControllable } from '../../hooks/use-controllable'

enum ComboboxState {
Open,
Expand Down Expand Up @@ -311,10 +312,11 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
props: Props<
TTag,
ComboboxRenderPropArg<TType>,
'value' | 'onChange' | 'disabled' | 'name' | 'nullable' | 'multiple' | 'by'
'value' | 'defaultValue' | 'onChange' | 'by' | 'disabled' | 'name' | 'nullable' | 'multiple'
> & {
value: TType
onChange(value: TType): void
value?: TType
defaultValue?: TType
onChange?(value: TType): void
by?: (keyof TType & string) | ((a: TType, z: TType) => boolean)
disabled?: boolean
__demoMode?: boolean
Expand All @@ -325,16 +327,18 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
ref: Ref<TTag>
) {
let {
value: controlledValue,
defaultValue,
onChange: controlledOnChange,
name,
value,
onChange: theirOnChange,
by = (a, z) => a === z,
disabled = false,
__demoMode = false,
nullable = false,
multiple = false,
...theirProps
} = props
let [value, theirOnChange] = useControllable(controlledValue, controlledOnChange, defaultValue)

let [state, dispatch] = useReducer(stateReducer, {
dataRef: createRef(),
Expand Down Expand Up @@ -430,8 +434,9 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
data.activeOptionIndex === null
? null
: (data.options[data.activeOptionIndex].dataRef.current.value as TType),
value,
}),
[data, disabled]
[data, disabled, value]
)

let syncInputValue = useCallback(() => {
Expand Down Expand Up @@ -495,7 +500,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
let onChange = useEvent((value: unknown) => {
return match(data.mode, {
[ValueMode.Single]() {
return theirOnChange(value as TType)
return theirOnChange?.(value as TType)
},
[ValueMode.Multi]() {
let copy = (data.value as TActualType[]).slice()
Expand All @@ -507,7 +512,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
copy.splice(idx, 1)
}

return theirOnChange(copy as unknown as TType)
return theirOnChange?.(copy as unknown as TType)
},
})
})
Expand Down
19 changes: 12 additions & 7 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { objectToFormEntries } from '../../utils/form'
import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
import { useControllable } from '../../hooks/use-controllable'

enum ListboxStates {
Open,
Expand Down Expand Up @@ -311,10 +312,11 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
props: Props<
TTag,
ListboxRenderPropArg,
'value' | 'onChange' | 'disabled' | 'horizontal' | 'name' | 'multiple' | 'by'
'value' | 'defaultValue' | 'onChange' | 'by' | 'disabled' | 'horizontal' | 'name' | 'multiple'
> & {
value: TType
onChange(value: TType): void
value?: TType
defaultValue?: TType
onChange?(value: TType): void
by?: (keyof TType & string) | ((a: TType, z: TType) => boolean)
disabled?: boolean
horizontal?: boolean
Expand All @@ -324,9 +326,10 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
ref: Ref<TTag>
) {
let {
value,
value: controlledValue,
defaultValue,
name,
onChange,
onChange: controlledOnChange,
by = (a, z) => a === z,
disabled = false,
horizontal = false,
Expand All @@ -336,6 +339,8 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
const orientation = horizontal ? 'horizontal' : 'vertical'
let listboxRef = useSyncRefs(ref)

let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue)

let reducerBag = useReducer(stateReducer, {
listboxState: ListboxStates.Closed,
propsRef: {
Expand Down Expand Up @@ -410,8 +415,8 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
)

let slot = useMemo<ListboxRenderPropArg>(
() => ({ open: listboxState === ListboxStates.Open, disabled }),
[listboxState, disabled]
() => ({ open: listboxState === ListboxStates.Open, disabled, value }),
[listboxState, disabled, value]
)

let ourProps = { ref: listboxRef }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit, objectToFormEntries } from '../../utils/form'
import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
import { useControllable } from '../../hooks/use-controllable'

interface Option<T = unknown> {
id: string
Expand Down Expand Up @@ -103,7 +104,9 @@ function stateReducer<T>(state: StateDefinition<T>, action: Actions) {
// ---

let DEFAULT_RADIO_GROUP_TAG = 'div' as const
interface RadioGroupRenderPropArg {}
interface RadioGroupRenderPropArg<TType> {
value: TType
}
type RadioGroupPropsWeControl = 'role' | 'aria-labelledby' | 'aria-describedby' | 'id'

let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
Expand All @@ -112,18 +115,27 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
>(
props: Props<
TTag,
RadioGroupRenderPropArg,
RadioGroupRenderPropArg<TType>,
RadioGroupPropsWeControl | 'value' | 'onChange' | 'disabled' | 'name' | 'by'
> & {
value: TType
onChange(value: TType): void
value?: TType
defaultValue?: TType
onChange?(value: TType): void
by?: (keyof TType & string) | ((a: TType, z: TType) => boolean)
disabled?: boolean
name?: string
},
ref: Ref<HTMLElement>
) {
let { value, name, onChange, by = (a, z) => a === z, disabled = false, ...theirProps } = props
let {
value: controlledValue,
defaultValue,
name,
onChange: controlledOnChange,
by = (a, z) => a === z,
disabled = false,
...theirProps
} = props
let compare = useEvent(
typeof by === 'string'
? (a: TType, z: TType) => {
Expand All @@ -140,6 +152,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
let internalRadioGroupRef = useRef<HTMLElement | null>(null)
let radioGroupRef = useSyncRefs(internalRadioGroupRef, ref)

let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue)

let firstOption = useMemo(
() =>
options.find((option) => {
Expand All @@ -161,7 +175,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
)?.propsRef.current
if (nextOption?.disabled) return false

onChange(nextValue)
onChange?.(nextValue)

return true
})

Expand Down Expand Up @@ -266,6 +281,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
onKeyDown: handleKeyDown,
}

let slot = useMemo<RadioGroupRenderPropArg<TType>>(() => ({ value }), [value])

return (
<DescriptionProvider name="RadioGroup.Description">
<LabelProvider name="RadioGroup.Label">
Expand All @@ -290,6 +307,7 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup<
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_RADIO_GROUP_TAG,
name: 'RadioGroup',
})}
Expand Down
21 changes: 16 additions & 5 deletions packages/@headlessui-react/src/components/switch/switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit } from '../../utils/form'
import { useEvent } from '../../hooks/use-event'
import { useControllable } from '../../hooks/use-controllable'

interface StateDefinition {
switch: HTMLButtonElement | null
Expand Down Expand Up @@ -101,16 +102,24 @@ let SwitchRoot = forwardRefWithAs(function Switch<
props: Props<
TTag,
SwitchRenderPropArg,
SwitchPropsWeControl | 'checked' | 'onChange' | 'name' | 'value'
SwitchPropsWeControl | 'checked' | 'defaultChecked' | 'onChange' | 'name' | 'value'
> & {
checked: boolean
onChange(checked: boolean): void
checked?: boolean
defaultChecked?: boolean
onChange?(checked: boolean): void
name?: string
value?: string
},
ref: Ref<HTMLElement>
) {
let { checked, onChange, name, value, ...theirProps } = props
let {
checked: controlledChecked,
defaultChecked = false,
onChange: controlledOnChange,
name,
value,
...theirProps
} = props
let id = `headlessui-switch-${useId()}`
let groupContext = useContext(GroupContext)
let internalSwitchRef = useRef<HTMLButtonElement | null>(null)
Expand All @@ -121,7 +130,9 @@ let SwitchRoot = forwardRefWithAs(function Switch<
groupContext === null ? null : groupContext.setSwitch
)

let toggle = useEvent(() => onChange(!checked))
let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)

let toggle = useEvent(() => onChange?.(!checked))
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
event.preventDefault()
Expand Down
23 changes: 23 additions & 0 deletions packages/@headlessui-react/src/hooks/use-controllable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useState } from 'react'
import { useEvent } from './use-event'

export function useControllable<T>(
controlledValue: T | undefined,
onChange?: (value: T) => void,
defaultValue?: T
) {
let [internalValue, setInternalValue] = useState(defaultValue)
let isControlled = controlledValue !== undefined

return [
(isControlled ? controlledValue : internalValue)!,
useEvent((value) => {
if (isControlled) {
return onChange?.(value)
} else {
setInternalValue(value)
return onChange?.(value)
}
}),
] as const
}

0 comments on commit bbc7b6d

Please sign in to comment.