A controllable component supports either parent-owned state or its own local state through one public API. Implement ControllableInput, a forwardRef wrapper around a native input whose mode is selected once on mount. It must route reserved state props itself while forwarding ordinary input attributes.
type ControllableInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'defaultValue' | 'onChange' | 'children'
> & {
value?: string | number | readonly string[] | null;
defaultValue?: string | number | readonly string[] | null;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
};
const ControllableInput: React.ForwardRefExoticComponent<
ControllableInputProps & React.RefAttributes<HTMLInputElement>
>;
Passing a defined value selects controlled mode. The parent must feed the next value back:
function NameField() {
const [name, setName] = React.useState('Ada');
return React.createElement(ControllableInput, {
value: name,
onChange: (event) => setName(event.target.value),
});
}
Omitting value selects uncontrolled mode. The component owns later edits:
React.createElement(ControllableInput, {
defaultValue: 'draft',
onChange: (event) => console.log(event.target.value),
placeholder: 'Title',
});
value is not undefined. null therefore selects controlled mode and renders as an empty string.value ?? '', call onChange(event), and never copy the typed value into internal state.defaultValue ?? '', update local state from event.target.value, and still call onChange(event).value is undefined; an uncontrolled instance ignores a later value prop. Later defaultValue changes never reset local edits.value, defaultValue, or onChange through blindly, and do not pass children because input is a void element.ControllableInput chooses one state owner on mount, then routes every later prop and change event according to that fixed choice.
React inputs can be driven by a value prop or allowed to keep their own value. A reusable wrapper often needs to support both forms, but deciding again on every render creates a dangerous third behavior: the owner changes halfway through the component's life. That can discard local edits, revive stale defaults, and trigger React's controlled/uncontrolled warning.
The solution has two parts. Capture the mode once, and always render the native input through one value channel. The wrapper may be uncontrolled to its caller while still using React state to control the native element internally.
The first value answers one question: who owns the source of truth for this mounted instance? A ref remembers that answer without causing a render.
The tempting version picks a source on every render:
function ControllableInput({ value, defaultValue, onChange, ...props }) {
const [localValue, setLocalValue] = React.useState(defaultValue ?? '');
const controlled = value !== undefined;
return React.createElement('input', {
...props,
value: controlled ? value : localValue,
onChange: controlled ? onChange : (event) => setLocalValue(event.target.value),
});
}
Removing value flips a controlled instance to local state that may contain an old default. Adding value to an uncontrolled instance suddenly discards its live edit. The uncontrolled branch also forgets to notify the caller, and the component cannot receive a ref.
const React = require('react');
const ControllableInput = React.forwardRef(function ControllableInput(
{ value, defaultValue, onChange, ...inputProps },
ref,
) {
// A ref freezes ownership without scheduling another render.
const isControlled = React.useRef(value !== undefined);
const [internalValue, setInternalValue] = React.useState(
() => defaultValue ?? '',
);
function handleChange(event) {
if (!isControlled.current) {
setInternalValue(event.target.value);
}
if (typeof onChange === 'function') {
onChange(event);
}
}
const renderedValue = isControlled.current
? value ?? ''
: internalValue;
return React.createElement('input', {
...inputProps,
ref,
value: renderedValue,
onChange: handleChange,
});
});
ControllableInput.displayName = 'ControllableInput';
module.exports = { ControllableInput };
Destructuring removes the three reserved props before inputProps is spread. The component then supplies exactly one value and one routed handler. This avoids passing both value and defaultValue, while the ref reaches the real input node.
In controlled mode, the prop is the rendered truth and an event is only a notification. In uncontrolled mode, the same event first updates local state and then notifies the caller. The native input always receives a defined value, so React sees a consistent native control even though the wrapper offers two ownership modes.
This matches React's distinction between controlled and uncontrolled inputs: a controlled value needs an onChange path that updates its owner. Our wrapper does not assume the callback will update the prop. If the parent leaves value unchanged, typing leaves the visible text unchanged.
Mount with defaultValue="draft" and no value. The mode ref stores false, internal state initializes to draft, and the input renders that value. Typing ready calls handleChange; it stores ready locally and forwards the same event to onChange.
The parent now rerenders with value="server" and defaultValue="fresh". The ref still says uncontrolled, so both new props are ignored and the input keeps ready. Typing again continues to update local state. To start a controlled instance, the parent must unmount this one and mount a new one, commonly by changing its React key.
value. value !== undefined inside every render permits ownership to switch. Store the first answer in a ref.null as uncontrolled. The contract uses only undefined to mean no controlled value. null selects controlled mode and normalizes to an empty string.value creates two sources of truth and a render of stale data. Render the prop directly.defaultValue. A default is an initializer, not a reset instruction. Pass a lazy initializer to useState so later prop changes do nothing.{ value: rendered, ...props } lets an incoming value, defaultValue, or onChange overwrite the routing. Destructure first, spread ordinary props, then set the routed fields.onChange(event) so existing form handlers see the native target and exact typed value.useControllableState hook for components such as accordions and dialogs that expose a value rather than a native change event.key, keeping reset semantics in React's identity model instead of adding an imperative API.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A controllable component supports either parent-owned state or its own local state through one public API. Implement ControllableInput, a forwardRef wrapper around a native input whose mode is selected once on mount. It must route reserved state props itself while forwarding ordinary input attributes.
type ControllableInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'defaultValue' | 'onChange' | 'children'
> & {
value?: string | number | readonly string[] | null;
defaultValue?: string | number | readonly string[] | null;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
};
const ControllableInput: React.ForwardRefExoticComponent<
ControllableInputProps & React.RefAttributes<HTMLInputElement>
>;
Passing a defined value selects controlled mode. The parent must feed the next value back:
function NameField() {
const [name, setName] = React.useState('Ada');
return React.createElement(ControllableInput, {
value: name,
onChange: (event) => setName(event.target.value),
});
}
Omitting value selects uncontrolled mode. The component owns later edits:
React.createElement(ControllableInput, {
defaultValue: 'draft',
onChange: (event) => console.log(event.target.value),
placeholder: 'Title',
});
value is not undefined. null therefore selects controlled mode and renders as an empty string.value ?? '', call onChange(event), and never copy the typed value into internal state.defaultValue ?? '', update local state from event.target.value, and still call onChange(event).value is undefined; an uncontrolled instance ignores a later value prop. Later defaultValue changes never reset local edits.value, defaultValue, or onChange through blindly, and do not pass children because input is a void element.ControllableInput chooses one state owner on mount, then routes every later prop and change event according to that fixed choice.
React inputs can be driven by a value prop or allowed to keep their own value. A reusable wrapper often needs to support both forms, but deciding again on every render creates a dangerous third behavior: the owner changes halfway through the component's life. That can discard local edits, revive stale defaults, and trigger React's controlled/uncontrolled warning.
The solution has two parts. Capture the mode once, and always render the native input through one value channel. The wrapper may be uncontrolled to its caller while still using React state to control the native element internally.
The first value answers one question: who owns the source of truth for this mounted instance? A ref remembers that answer without causing a render.
The tempting version picks a source on every render:
function ControllableInput({ value, defaultValue, onChange, ...props }) {
const [localValue, setLocalValue] = React.useState(defaultValue ?? '');
const controlled = value !== undefined;
return React.createElement('input', {
...props,
value: controlled ? value : localValue,
onChange: controlled ? onChange : (event) => setLocalValue(event.target.value),
});
}
Removing value flips a controlled instance to local state that may contain an old default. Adding value to an uncontrolled instance suddenly discards its live edit. The uncontrolled branch also forgets to notify the caller, and the component cannot receive a ref.
const React = require('react');
const ControllableInput = React.forwardRef(function ControllableInput(
{ value, defaultValue, onChange, ...inputProps },
ref,
) {
// A ref freezes ownership without scheduling another render.
const isControlled = React.useRef(value !== undefined);
const [internalValue, setInternalValue] = React.useState(
() => defaultValue ?? '',
);
function handleChange(event) {
if (!isControlled.current) {
setInternalValue(event.target.value);
}
if (typeof onChange === 'function') {
onChange(event);
}
}
const renderedValue = isControlled.current
? value ?? ''
: internalValue;
return React.createElement('input', {
...inputProps,
ref,
value: renderedValue,
onChange: handleChange,
});
});
ControllableInput.displayName = 'ControllableInput';
module.exports = { ControllableInput };
Destructuring removes the three reserved props before inputProps is spread. The component then supplies exactly one value and one routed handler. This avoids passing both value and defaultValue, while the ref reaches the real input node.
In controlled mode, the prop is the rendered truth and an event is only a notification. In uncontrolled mode, the same event first updates local state and then notifies the caller. The native input always receives a defined value, so React sees a consistent native control even though the wrapper offers two ownership modes.
This matches React's distinction between controlled and uncontrolled inputs: a controlled value needs an onChange path that updates its owner. Our wrapper does not assume the callback will update the prop. If the parent leaves value unchanged, typing leaves the visible text unchanged.
Mount with defaultValue="draft" and no value. The mode ref stores false, internal state initializes to draft, and the input renders that value. Typing ready calls handleChange; it stores ready locally and forwards the same event to onChange.
The parent now rerenders with value="server" and defaultValue="fresh". The ref still says uncontrolled, so both new props are ignored and the input keeps ready. Typing again continues to update local state. To start a controlled instance, the parent must unmount this one and mount a new one, commonly by changing its React key.
value. value !== undefined inside every render permits ownership to switch. Store the first answer in a ref.null as uncontrolled. The contract uses only undefined to mean no controlled value. null selects controlled mode and normalizes to an empty string.value creates two sources of truth and a render of stale data. Render the prop directly.defaultValue. A default is an initializer, not a reset instruction. Pass a lazy initializer to useState so later prop changes do nothing.{ value: rendered, ...props } lets an incoming value, defaultValue, or onChange overwrite the routing. Destructure first, spread ordinary props, then set the routed fields.onChange(event) so existing form handlers see the native target and exact typed value.useControllableState hook for components such as accordions and dialogs that expose a value rather than a native change event.key, keeping reset semantics in React's identity model instead of adding an imperative API.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.