| |

React 82 ⚛️ Control Props Pattern

⏱️ Estimated read: 10 min | |

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

AspectControlledUncontrolled
Control propDefined (value={x})undefined
Source of truthParent stateInternal state
Update pathonChange(newValue)setState(newValue)
SynchronizationPossibleNot possible

Key Implementation Rules

RuleReason
Check prop !== undefinedDistinguishes controlled from uncontrolled
Read prop directly when controlledAvoids stale state and extra renders
Never sync prop to state with useEffectCauses re-render loops and multiple callbacks
Call callback with suggested valueParent decides whether to apply it

Libraries Using This Pattern

LibraryControl Props
DownshiftisOpen, selectedItem, inputValue
Radix UIvalue, 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

PitfallWhy It HappensFix
Multiple onChange callsInternal state + effect syncingRead prop directly, no effect
Stale renderInternal state lags behind propRender from prop when controlled
Component cannot be resetUncontrolled onlySupport controlled mode
TypeScript error on optional callbackCallback may be undefinedonChange?.()
Switching modes mid-lifeProp goes from defined to undefinedDocument 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

ItemValue
PatternComponent supports both controlled and uncontrolled modes
Controlled checkcontrolProp !== undefined
Controlled renderRead from prop
Controlled updateCall onChange with suggested value
Uncontrolled renderRead from internal state
Uncontrolled updatesetState internally
Anti-patternSync 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 undefined check is the switch. A control prop that is undefined means 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 useEffect causes extra renders and multiple onChange calls .
  • 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 onChange with 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!