Controlled State
Overview
Our controllable components follow a consistent naming convention for controlled state:
| Prop Pattern | Description | Examples |
|---|---|---|
on[Property]Change | Callback fired when the value changes | onValueChange, onOpenChange, onCheckedChange |
[property] | The controlled value prop | value, open, checked |
default[Property] | Initial value when uncontrolled | defaultValue, 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
valueisundefined - Controlled Mode: When
valueis defined (typically this also includesnull,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
valueprop 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)}
/>
)
}