Skip to main content

Control Props: Mastering Flexible Component State

The control props pattern gives the parent component explicit control over a child's state by passing the state value as a prop and a callback to update it. This is the React equivalent of HTML form inputs: an <input value="hello" onChange={...} /> is "controlled" because the parent owns the state. A component using this pattern can also support an "uncontrolled" variant (no state prop, component manages its own state), giving consumers flexibility to use whichever fits their needs.

I adopted control props after a product team asked for a modal that could be opened by external events (button clicks, timers, keyboard shortcuts) while also supporting the typical "click the button to open" flow. Control props solved it in an afternoon—the same component worked both ways without duplication.

Controlled vs. Uncontrolled Components

The Difference: Who Owns State?

// UNCONTROLLED: component owns state
function ToggleUncontrolled({ defaultOpen = false }) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);

return (
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Collapse' : 'Expand'}
</button>
);
}

// Usage: component manages itself
<ToggleUncontrolled defaultOpen={false} />
// CONTROLLED: parent owns state
function ToggleControlled({ isOpen, onChange }) {
return (
<button onClick={() => onChange(!isOpen)}>
{isOpen ? 'Collapse' : 'Expand'}
</button>
);
}

// Usage: parent controls everything
const [isOpen, setIsOpen] = React.useState(false);
<ToggleControlled isOpen={isOpen} onChange={setIsOpen} />

In the controlled version, the parent decides what isOpen is and when it changes. The component is just a presentation layer.

Building a Flexible Disclosure Component

A disclosure (expandable/collapsible section) is perfect for demonstrating both patterns:

// Supports both controlled AND uncontrolled modes
function Disclosure({
children,
defaultOpen = false,
isOpen: controlledIsOpen, // undefined = uncontrolled
onChange, // for controlled mode
trigger = 'Click to expand',
}) {
// If isOpen prop is provided, use controlled mode; else use internal state
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = controlledIsOpen !== undefined;
const isOpen = isControlled ? controlledIsOpen : uncontrolledOpen;

// Handle toggle: update either controlled or uncontrolled state
const toggle = () => {
const newValue = !isOpen;
if (isControlled) {
onChange?.(newValue);
} else {
setUncontrolledOpen(newValue);
}
};

return (
<div>
<button onClick={toggle}>{trigger}</button>
{isOpen && <div>{children}</div>}
</div>
);
}

// UNCONTROLLED: use it like a regular toggle
export function UncontrolledExample() {
return (
<Disclosure defaultOpen={false} trigger="Expand FAQ">
This answer is hidden until expanded.
</Disclosure>
);
}

// CONTROLLED: parent controls multiple disclosures
export function ControlledExample() {
const [activeTab, setActiveTab] = React.useState('about');

return (
<div>
<Disclosure
isOpen={activeTab === 'about'}
onChange={(isOpen) => setActiveTab(isOpen ? 'about' : null)}
trigger="About"
>
About content
</Disclosure>

<Disclosure
isOpen={activeTab === 'settings'}
onChange={(isOpen) => setActiveTab(isOpen ? 'settings' : null)}
trigger="Settings"
>
Settings content
</Disclosure>

{/* In controlled mode, only one can be open at a time */}
</div>
);
}

The same component works uncontrolled (simple, self-contained) or controlled (flexible, parent-driven). This dual pattern is why many popular libraries (React Router, Headless UI) default to it.

Real-World: Controlled Input with Debounce

A search input that's controlled lets the parent decide whether to apply debouncing, validation, or other side effects:

// Controlled input component (pure presentation)
function SearchInput({
value,
onChange,
placeholder = 'Search...',
}) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label="Search"
/>
);
}

// Parent owns state and adds debouncing
function SearchWithDebounce() {
const [query, setQuery] = React.useState('');
const [results, setResults] = React.useState([]);
const debounceTimer = React.useRef(null);

const handleSearchChange = (newQuery) => {
setQuery(newQuery);

// Debounce the fetch
clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
if (newQuery.trim()) {
fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
.then(res => res.json())
.then(data => setResults(data));
} else {
setResults([]);
}
}, 300);
};

return (
<div>
<SearchInput value={query} onChange={handleSearchChange} />
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
}

The SearchInput is purely controlled—it just renders the input and calls onChange. The parent adds debouncing, validation, or any other behavior without the input needing to know about it.

Control Props with Validation

Control props work perfectly with form libraries that validate as you type:

// Form field that's controlled (parent handles validation)
function ControlledFormField({
name,
value,
onChange,
error,
label,
type = 'text',
}) {
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={!!error}
aria-describedby={error ? `${name}-error` : undefined}
/>
{error && <span id={`${name}-error`}>{error}</span>}
</div>
);
}

// Form container manages validation
function RegistrationForm() {
const [formData, setFormData] = React.useState({
email: '',
password: '',
});
const [errors, setErrors] = React.useState({});

const handleFieldChange = (name, value) => {
setFormData(prev => ({ ...prev, [name]: value }));

// Validate on change
const newErrors = { ...errors };
if (name === 'email' && !value.includes('@')) {
newErrors.email = 'Invalid email';
} else {
delete newErrors[name];
}
setErrors(newErrors);
};

const handleSubmit = (e) => {
e.preventDefault();
// Submit form...
};

return (
<form onSubmit={handleSubmit}>
<ControlledFormField
name="email"
value={formData.email}
onChange={(val) => handleFieldChange('email', val)}
error={errors.email}
label="Email"
/>
<ControlledFormField
name="password"
value={formData.password}
onChange={(val) => handleFieldChange('password', val)}
error={errors.password}
label="Password"
type="password"
/>
<button type="submit">Register</button>
</form>
);
}

The form field is pure; the form manages all logic. Easy to test, easy to reuse, easy to add validation rules.

When to Use Each Approach

ModeUse WhenExample
UncontrolledComponent is self-contained and parent doesn't need to react to state changesSingle disclosure, simple toggle, file input
ControlledParent needs to react to changes, coordinate multiple components, or apply external logicForm fields with validation, searchable lists, coordinated tabs
Both (Dual)Maximum flexibility for consumersLibrary components, design system, popular UI libraries

Key Takeaways

  • Controlled components pass state as props and require an onChange callback, giving the parent complete control.
  • Uncontrolled components manage their own state internally (using defaultValue) and are simpler but less flexible.
  • Many production components support both modes: controlled when an onChange prop is provided, uncontrolled otherwise.
  • Control props enable parent-driven logic injection: debouncing, validation, coordination with other components, or external state management (Redux, Zustand).
  • Dual-mode components maximize usability—consumers choose controlled or uncontrolled based on their needs.

Frequently Asked Questions

How do I know which mode to default to?

If the component is simple and self-contained (a single toggle, a disclosure), uncontrolled is fine. If it needs to coordinate with other state or the parent needs to react to changes, controlled is better. The dual-mode approach lets consumers choose, which is ideal for libraries.

What's the difference between control props and HOCs?

HOCs enhance the component from outside; control props are a contract within the component's API. Control props are simpler and more explicit. Use HOCs when you need to wrap multiple components; use control props when you need to offer flexibility in a single component's state management.

Can I use control props with TypeScript?

Yes. Type the value and onChange callback explicitly: value: string; onChange: (value: string) => void. TypeScript ensures type safety across parent and child.

Does control props impact performance?

Not inherently. However, if the parent re-renders frequently (because state is updated on every keystroke), the controlled child re-renders too. You can optimize with React.memo or move state management closer to the input.

When would I use uncontrolled mode in production?

For any component where the parent doesn't need to react to state changes. A file input is often uncontrolled because the parent only needs the file when the form submits, not on every change.

Further Reading