Controlled State

Overview

Our controllable components follow a consistent naming convention for controlled state:

Prop PatternDescriptionExamples
on[Property]ChangeCallback fired when the value changesonValueChange, onOpenChange, onCheckedChange
[property]The controlled value propvalue, open, checked
default[Property]Initial value when uncontrolleddefaultValue, defaultOpen, defaultChecked

Uncontrolled mode (default): When the value prop is undefined, the component manages its own state starting with the default[Property] prop.

Controlled mode: When the value prop is provided, the parent component controls the state. All state changes must come through the value prop: the component will call its on[Property]Change handler but won't update its internal state until the parent updates the [property] prop.

Keep reading to learn more about this pattern.

Implementation

Required Properties

Every component supporting this pattern must implement three key properties. We'll use an example with a property named value:

interface ControllableComponentProps<T> {
  onValueChange?: (value: T) => void
  value?: T // Controlled value
  defaultValue?: T // Initial uncontrolled value
}

State Determination Logic

The component's mode is determined by a simple rule:

  • Uncontrolled Mode: When value is undefined
  • Controlled Mode: When value is defined (typically this also includes null, false, 0, etc.)

Implementation Example

interface ToggleProps {
  defaultValue?: boolean // Initial uncontrolled value
  onValueChange?: (value: boolean) => void
  value?: boolean // Controlled value
}

function Toggle({defaultValue = false, onValueChange, value}: ToggleProps) {
  const [internalValue, setInternalValue] = useState(defaultValue)

  // Determine if component is controlled
  const isControlled = value !== undefined
  const currentValue = isControlled ? value : internalValue

  const handleToggle = () => {
    const nextValue = !currentValue

    // Always call onValueChange
    onValueChange?.(nextValue)

    // Only update internal state if uncontrolled
    if (!isControlled) {
      setInternalValue(nextValue)
    }
  }

  return <button onClick={handleToggle}>{currentValue ? "On" : "Off"}</button>
}

Usage Patterns

Uncontrolled Usage (Default Behavior)

The component manages its own state internally:

// Component starts with defaultValue value and manages state internally
<Toggle defaultValue={true} onValueChange={(value) => console.log(value)} />

This is ideal for simple use cases where the parent component doesn't need to control the state, but still might want to respond to state changes.

Controlled Usage

The parent component manages the state:

function ParentComponent() {
  const [isToggled, setIsToggled] = useState(false)

  return <Toggle value={isToggled} onValueChange={setIsToggled} />
}

This gives you complete control over the state of the component, which enables complex validation and business logic.

Benefits of This Pattern

  • Swapping between modes is trivial: either pass the value prop or let the component handle it. Your tests still work, your API stays the same.

  • State ownership is obvious: if you pass a value, you're controlling it. If you don't, the component handles it. No weird edge cases where both the parent and component think they own the state.

Common Pitfalls and Solutions

Problem: Switching from controlled to uncontrolled (or vice versa) during component lifecycle.

Solution: Always choose the appropriate mode at the beginning of the component's lifecycle.


Problem: Providing value but not onValueChange, making the component read-only.

Solution: Always provide an onValueChange prop in controlled mode.

Use this pattern in your own components

Toggle Example

import {useControlledState} from "@qualcomm-ui/react-core/state"

function Toggle({onValueChange, value, defaultValue = false}: ToggleProps) {
  const [currentValue, setCurrentValue] = useControlledState(
    value,
    defaultValue,
    onValueChange,
  )

  return (
    <button onClick={() => setCurrentValue(!currentValue)}>
      {currentValue ? "On" : "Off"}
    </button>
  )
}

Input Example

import {useControlledState} from "@qualcomm-ui/react-core/state"

interface InputProps {
  onValueChange?: (value: string) => void
  value?: string
  defaultValue?: string
}

function Input({onValueChange, value, defaultValue = ""}: InputProps) {
  const [currentValue, setCurrentValue] = useControlledState(
    value,
    defaultValue,
    onValueChange,
  )

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
    />
  )
}