Terracotta
GitHub

Controlled and uncontrolled

Stateful components come in two shapes. Terracotta picks between them by looking at which prop you passed:

Uncontrolled prop Controlled prop Used by
defaultOpen isOpen Disclosure, Dialog, AlertDialog, Popover, ContextMenu, CommandBar, Listbox, Combobox
defaultValue value Accordion, Select, Listbox, Combobox, Command, RadioGroup, TabGroup
defaultChecked checked Checkbox
defaultPressed pressed Toggle

Uncontrolled: the component owns a signal and updates itself. onChange only tells you what happened.

Controlled: the component never writes state. It calls onChange, and nothing moves on screen until you update the value you passed in.

// Uncontrolled: works on its own
<Disclosure defaultOpen={false}>…</Disclosure>

// Controlled: you own the signal
const [open, setOpen] = createSignal(false);
<Disclosure isOpen={open()} onChange={setOpen}>…</Disclosure>

The choice is made once, when the component is created. Do not switch a component from defaultOpen to isOpen at runtime.

A controlled component whose value never changes will not move. If a click seems to do nothing, check that onChange writes back to the signal you pass in.

Render props

children can be a function. It receives the component's state, so you can read that state without reaching for context:

<Disclosure defaultOpen={false}>
  {({ isOpen, toggle }) => (
    <>
      <DisclosureButton>{isOpen() ? 'Hide' : 'Show'}</DisclosureButton>
      <DisclosurePanel>Content</DisclosurePanel>
    </>
  )}
</Disclosure>

Two details are worth knowing:

  • On root components, the ones that create the state, any function child is called with the state.
  • On descendant components, a function counts as a render prop only if it declares exactly one parameter. () => <span /> renders as-is. state => <span /> is called with the state.

Reaching the state from further away

The same states are available through the …StateChild components and use…State() hooks in states.md. Use those when the markup that needs the state sits several components below the root, where threading a render prop down would only add noise.

function CloseButton() {
  const state = useDisclosureState();
  return <button onClick={() => state.close()}>Close</button>;
}

Every component page names the state it owns, so you know which hook applies.