pyreon

Controlled / uncontrolled components

The pattern

Use useControllableState from @pyreon/hooks for any primitive that exposes both controlled (value + onChange) and uncontrolled (defaultValue) props:

import { useControllableState } from '@pyreon/hooks'

function Toggle(props: {
  checked?: boolean
  defaultChecked?: boolean
  onChange?: (next: boolean) => void
}) {
  const [checked, setChecked] = useControllableState({
    value: () => props.checked,
    defaultValue: () => props.defaultChecked ?? false,
    onChange: props.onChange,
  })

  return (
    <button onClick={() => setChecked(!checked())}>
      {() => (checked() ? 'on' : 'off')}
    </button>
  )
}

Key rules:

  • Pass value and defaultValue as functions, not values. The function is invoked inside a reactive scope so signal reads track — the hook can't detect prop-driven controlled/uncontrolled transitions without a function.

  • The returned checked is a Signal<boolean> — call it to read, and call setChecked to write.

  • onChange is optional in both modes; if omitted and the component is controlled, writes are no-ops (which is exactly what React's controlled contract prescribes).

Why

Every primitive in @pyreon/ui-primitives needs this pattern. Before useControllableState landed, the logic was reimplemented by hand across ~15 components as:

const isControlled = () => props.value !== undefined
const internalValue = signal(props.defaultValue ?? null)
const value = () => (isControlled() ? props.value : internalValue())

That shape is subtly wrong — props.value is read once at setup in the isControlled closure, so switching modes at runtime breaks. The helper gets it right, once, and covers all primitives.

Anti-pattern

// BROKEN — manual isControlled + signal + getter
function Toggle(props) {
  const isControlled = props.checked !== undefined    // captured ONCE
  const internal = signal(props.defaultChecked ?? false)
  const checked = () => (isControlled ? props.checked : internal())
  // If props.checked transitions from undefined to defined, `isControlled`
  // stays false and the controlled path is never taken.
}
  • Anti-pattern: "Duplicating controlled/uncontrolled pattern" in architecture category

  • Reference API: useControllableState in @pyreon/hooks — see get_api({ package: "hooks", symbol: "useControllableState" })

Controlled / uncontrolled components