A while ago I wrote about creating React components that can be both controlled and uncontrolled.
In the meantime, I've created a reusable React Hook for this pattern: useControlledProps()
.
Example
Here is how you would use it to implement a reusable text input with character count.
TSX
function CharacterCountInput(props: React.InputHTMLAttributes<HTMLInputElement>) {const { value, ...rest } = useControlledProps(props)return (<><input value={value} {...rest} />{value.length}</>)}
Now you can use this <CharacterCountInput>
component in both a controlled or uncontrolled way:
TSX
function App() {const [value, setValue] = useState('')const onChange: React.ChangeEventHandler = (e) => {setValue(value)}return (<>{/* Controlled: */}<CharacterCountInput value={value} onChange={onChange} />{/* Uncontrolled: */}<CharacterCountInput /></>)}
Code
Here is the code for the hook, feel free to copy-paste it into your project!
useControlledProps.ts
type ControlledElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElementinterface ControlledProps<El extends ControlledElement> {value?: string | ReadonlyArray<string> | number | undefinedonChange?: React.ChangeEventHandler<El> | undefineddefaultValue?: string | number | ReadonlyArray<string> | undefined}function useControlledProps<El extends ControlledElement, T extends ControlledProps<El>>({value: valueFromProps,onChange: onChangeFromProps,defaultValue,...rest}: T) {const isControlled = typeof valueFromProps !== 'undefined'const hasDefaultValue = typeof defaultValue !== 'undefined'const [internalValue, setInternalValue] = useState<T['value']>(hasDefaultValue ? defaultValue : '')const value = isControlled ? valueFromProps : internalValueconst onChange = useCallback<React.ChangeEventHandler<El>>((e) => {// When the user types, we will call props.onChange if it exists.// We do this even if there is no props.value (and the component// is uncontrolled.)if (onChangeFromProps) {onChangeFromProps(e)}// If the component is uncontrolled, we need to update our// internal value here.if (!isControlled) {setInternalValue(e.target.value)}},[isControlled, onChangeFromProps])return { value: value as string, onChange, ...rest }}
Check out the CodeSandbox for a complete example.