React 82 ⚛️ Control Props Pattern
The control props pattern is the mechanism that powers React’s own <input>. When you pass a value prop to an input, React stops managing the input’s state internally and defers to you. It still tells you what it would have done via onChange, but the decision is yours. The pattern generalizes that behavior to any custom component: the component manages its own state by default, but if the consumer provides a “control prop” like value or open, the component hands over control and only suggests changes through a callback .
The distinction between controlled and uncontrolled is not binary. A component can support both modes in a single implementation. The rule is simple: if the control prop is undefined, the component uses its internal state; if it is defined, the component uses the prop and ignores its internal state for rendering . This means the same component can be used casually in one place (uncontrolled, less boilerplate) and precisely in another (controlled, fully synchronized with external state) .
Key point: A component with a control prop checks whether that prop is undefined. If it is, the component manages its own state and updates it internally. If it is defined, the component renders from the prop and calls the onChange callback with the suggested new value instead of updating internal state. This avoids the anti-pattern of syncing props to state with useEffect, which causes extra re-renders and potential loops .
Why the pattern exists
The synchronization problem. Two separate toggle components each manage their own on state. Clicking one does not affect the other. To make them stay in sync, the state must be lifted to their common parent, and the parent must pass on and onToggle to both . The control props pattern is the mechanism that makes this lifting possible for custom components, not just native inputs. The parent becomes the single source of truth, and the components become pure views of that truth .
The anti-pattern of prop-to-state syncing. A naive implementation keeps internal state and tries to sync it to the prop with useEffect. When the prop changes, the effect sets internal state, which triggers a re-render, which may trigger onChange again. This causes multiple onChange calls for a single user action and can produce infinite loops . The control props pattern avoids this entirely: when controlled, the component does not keep internal state for the controlled value. It reads the prop directly .
The flexibility problem. A component that only supports uncontrolled mode cannot be synchronized with external state, cannot be reset programmatically, and cannot be validated on every keystroke by a parent. A component that only supports controlled mode forces every consumer to write state management boilerplate even for simple cases. Supporting both modes gives the consumer the choice .
a. The basic implementation
The implementation has three pieces: a function that determines whether the component is controlled, a function that reads the current value from either the prop or the internal state, and a function that updates the value by either calling the callback or setting internal state.
import { useState } from 'react'
function Toggle({ on: controlledOn, onToggle, children }) {
// Internal state is only used when the component is uncontrolled
const [internalOn, setInternalOn] = useState(false)
// A component is controlled if the control prop is not undefined
// This check is the entire mechanism
const isControlled = controlledOn !== undefined
// The current value comes from the prop when controlled,
// or from internal state when uncontrolled
const on = isControlled ? controlledOn : internalOn
// The update function either calls the callback (controlled)
// or updates internal state (uncontrolled)
function toggle() {
const nextValue = !on
if (isControlled) {
// The component suggests the change; the parent decides
onToggle?.(nextValue)
} else {
// The component owns the state and applies the change itself
setInternalOn(nextValue)
}
}
return (
<button onClick={toggle} aria-pressed={on}>
{on ? 'On' : 'Off'}
</button>
)
}
The controlledOn !== undefined check is the dividing line. A parent that passes on={true} gets a controlled component. A parent that passes nothing gets an uncontrolled component that manages its own state . The optional chaining onToggle?.(nextValue) ensures that a controlled component without a callback does not crash; it simply cannot be toggled, which is the correct behavior for a read-only controlled component.
b. Synchronizing two components with a control prop
The pattern’s purpose becomes clear when two instances share a single source of truth. The parent owns the state, and both children render from it.
function SyncExample() {
// The parent is the single source of truth
const [on, setOn] = useState(false)
return (
<div>
{/* Both toggles receive the same on prop and the same callback */}
<Toggle on={on} onToggle={setOn} />
<Toggle on={on} onToggle={setOn} />
</div>
)
}
Clicking either toggle calls setOn with the suggested value. The parent re-renders with the new on, and both toggles display the same state . This is the exact behavior of a controlled <input>: the value flows down from the parent, and the change flows up through the callback. The component does not know or care that another instance exists; it only knows that its value is controlled.
A third toggle without the on prop would remain independent, managing its own state, which demonstrates that both modes can coexist in the same tree .
c. The controlled/uncontrolled switch and the useEffect trap
The most common mistake is trying to keep internal state in sync with the prop using useEffect. The code below looks reasonable but is wrong:
// THE ANTI-PATTERN: do not do this
function BadToggle({ on: controlledOn, onToggle }) {
const [internalOn, setInternalOn] = useState(controlledOn)
// This effect runs when controlledOn changes
// It sets internal state, causing an extra render
useEffect(() => {
if (controlledOn !== undefined) {
setInternalOn(controlledOn)
}
}, [controlledOn])
// The render uses internal state, not the prop directly
// This means there is a render with stale state before the effect runs
function toggle() {
const next = !internalOn
setInternalOn(next)
onToggle?.(next)
}
return <button onClick={toggle}>{internalOn ? 'On' : 'Off'}</button>
}
The anti-pattern has two problems. First, the effect causes an extra re-render every time the prop changes, which is wasteful . Second, there is a render cycle where the internal state is stale (before the effect runs), which can cause visual flicker or incorrect intermediate states. The correct implementation never stores the controlled value in state. It reads the prop directly on every render, and it only calls setInternalOn when the component is uncontrolled .
Complete Example Session
The following session builds a controlled/uncontrolled Counter component and a parent that synchronizes two counters.
// ============================================
// PART 1: THE DUAL-MODE COMPONENT
// ============================================
import { useState } from 'react'
function Counter({ value: controlledValue, onChange, label }) {
// Internal state for uncontrolled mode
const [internalValue, setInternalValue] = useState(0)
// Controlled if the value prop is not undefined
const isControlled = controlledValue !== undefined
// Read from prop or state
const value = isControlled ? controlledValue : internalValue
function increment() {
const next = value + 1
if (isControlled) {
// Suggest the change; the parent decides
onChange?.(next)
} else {
// Apply the change internally
setInternalValue(next)
}
}
return (
<div>
<span>{label}: {value}</span>
<button onClick={increment}>+</button>
</div>
)
}
// ============================================
// PART 2: THE CONTROLLED PARENT
// ============================================
function SyncedCounters() {
// The parent owns the shared state
const [count, setCount] = useState(0)
return (
<div>
{/* Both counters are controlled by the same state */}
<Counter label="A" value={count} onChange={setCount} />
<Counter label="B" value={count} onChange={setCount} />
{/* This counter is uncontrolled; it manages its own state */}
<Counter label="Independent" />
</div>
)
}
The three parts cover the dual-mode component, the controlled parent that synchronizes two instances, and the uncontrolled instance that remains independent. What the session demonstrates is that the isControlled check is the entire switch, and the same component serves both use cases without duplication.
Quick Reference
Control Props Pattern
| Aspect | Controlled | Uncontrolled |
|---|---|---|
| Control prop | Defined (value={x}) | undefined |
| Source of truth | Parent state | Internal state |
| Update path | onChange(newValue) | setState(newValue) |
| Synchronization | Possible | Not possible |
Key Implementation Rules
| Rule | Reason |
|---|---|
Check prop !== undefined | Distinguishes controlled from uncontrolled |
| Read prop directly when controlled | Avoids stale state and extra renders |
Never sync prop to state with useEffect | Causes re-render loops and multiple callbacks |
| Call callback with suggested value | Parent decides whether to apply it |
Libraries Using This Pattern
| Library | Control Props |
|---|---|
| Downshift | isOpen, selectedItem, inputValue |
| Radix UI | value, onValueChange |
React <input> | value, onChange |
Best Practices
✅ Do This:
// Check undefined to determine mode
const isControlled = controlledValue !== undefined
// Read the prop directly when controlled
const value = isControlled ? controlledValue : internalValue
// Suggest changes through the callback
if (isControlled) onChange?.(next)
❌ Don’t Do This:
// Store the controlled value in state
const [value, setValue] = useState(controlledValue)
// Sync prop to state with useEffect
useEffect(() => { setValue(controlledValue) }, [controlledValue])
// Update internal state when controlled
setValue(next); onChange?.(next)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Multiple onChange calls | Internal state + effect syncing | Read prop directly, no effect |
| Stale render | Internal state lags behind prop | Render from prop when controlled |
| Component cannot be reset | Uncontrolled only | Support controlled mode |
| TypeScript error on optional callback | Callback may be undefined | onChange?.() |
| Switching modes mid-life | Prop goes from defined to undefined | Document that mode should not switch |
Real-World Examples
1. Controlled toggle
<Toggle on={on} onToggle={setOn} />
2. Uncontrolled toggle
<Toggle />
3. Check controlled mode
const isControlled = controlledOn !== undefined
4. Read value conditionally
const on = isControlled ? controlledOn : internalOn
5. Suggest change
if (isControlled) onToggle?.(next)
6. Apply change internally
if (!isControlled) setInternalOn(next)
7. Controlled input (native)
<input value={name} onChange={(e) => setName(e.target.value)} />
8. Downshift control props
useSelect({ selectedItem, onSelectedItemChange })
9. Radix select
<Select.Root value={value} onValueChange={setValue}>
10. Synchronized toggles
<Toggle on={on} onToggle={setOn} />
<Toggle on={on} onToggle={setOn} />
Visual
The Controlled/Uncontrolled Switch
┌──────────────────────────────────────────────┐
│ controlledOn === undefined? │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ UNCONTROLLED CONTROLLED │
│ read internal read prop │
│ set internal call onChange │
└──────────────────────────────────────────────┘
Synchronization Flow
┌──────────────────────────────────────────────┐
│ Parent: const [on, setOn] = useState(false) │
│ │ │
│ ▼ │
│ <Toggle on={on} onToggle={setOn} /> ×2 │
│ │ │
│ ▼ │
│ Click either toggle → onToggle(!on) │
│ │ │
│ ▼ │
│ Parent re-renders with new on │
│ │ │
│ ▼ │
│ Both toggles display the same state │
└──────────────────────────────────────────────┘
The Anti-Pattern
┌──────────────────────────────────────────────┐
│ BAD: │
│ state = controlledValue │
│ useEffect(() => setState(controlledValue)) │
│ │ │
│ ▼ │
│ prop changes → effect runs → state updates │
│ → extra render → possible loop │
│ │
│ GOOD: │
│ value = isControlled ? prop : state │
│ → prop is read directly on every render │
│ → no sync effect, no extra render │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Pattern | Component supports both controlled and uncontrolled modes |
| Controlled check | controlProp !== undefined |
| Controlled render | Read from prop |
| Controlled update | Call onChange with suggested value |
| Uncontrolled render | Read from internal state |
| Uncontrolled update | setState internally |
| Anti-pattern | Sync prop to state with useEffect |
Key takeaways:
- The pattern is the generalization of
<input value={...} onChange={...} />. The component manages its own state by default, but if the consumer provides a control prop, the component defers to it and only suggests changes . - The
undefinedcheck is the switch. A control prop that isundefinedmeans uncontrolled. A control prop that is defined means controlled. This is the entire mechanism . - Never store the controlled value in state. When controlled, read the prop directly on every render. Storing it in state and syncing with
useEffectcauses extra renders and multipleonChangecalls . - The parent becomes the single source of truth. Lifting the state to the parent and passing it to both children is what allows two components to stay synchronized. The control prop is the mechanism that makes the lifting work .
- The callback is a suggestion, not a command. When controlled, the component calls
onChangewith the value it would have set. The parent decides whether to apply it. This is what allows transformations like capitalization or validation to intercept the change . - Both modes can coexist. A component that supports both modes can be used uncontrolled for simple cases and controlled for synchronized or validated cases, without duplication .
Remember: The control props pattern is the React expression of a native input’s contract. The component owns its state until the consumer provides a control prop, at which point the consumer owns it and the component becomes a view. The undefined check is the dividing line. The callback is the suggestion. The parent is the source of truth. The pattern is not about adding complexity—it is about giving the consumer the choice between simplicity and control, and implementing that choice without duplicating the component or creating a synchronization loop.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!