Every stateful primitive has to answer one question. Who owns this state, the primitive or the app? Rather than forcing one answer, each supports both modes through the same prop shape. You start simple and reach for control when you need it.
This pattern is implemented once, in useControllableState from @lattice-ui/react-runtime, and every primitive root uses it. Learn the shape once and it applies everywhere.
The two modes
Every controllable prop comes as a trio: a controlled value, a default for uncontrolled use, and a change callback.
- Uncontrolled — pass only the
default*prop. The primitive owns the state. You read changes through the callback if you care. - Controlled — pass the value prop and the change callback. The app owns the state. The primitive renders what you give it and asks you to update.
The naming is consistent across the library:
| Concept | Controlled | Uncontrolled default | Change callback |
|---|---|---|---|
| Open state (overlays) | open | defaultOpen | onOpenChange |
| Selection value | value | defaultValue | onValueChange |
| Boolean state | checked | defaultChecked | onCheckedChange |
Some field primitives also expose a commit callback (for example onValueCommit) that fires only when the value is finalized, separate from the per-change callback.
Uncontrolled: let the primitive own it
This is the default, and it is the right choice most of the time. You give an initial value and forget about it.
import { Dialog } from "@lattice-ui/react-dialog";
export function HelpDialog() { return ( <Dialog.Root defaultOpen={false}> <Dialog.Trigger asChild> <textbutton Text="Help" Size={UDim2.fromOffset(80, 36)} /> </Dialog.Trigger> {/* The dialog opens and closes itself. */} </Dialog.Root> );}The trigger opens it, the close button (or an outside press) closes it, and you never touched the state. defaultOpen defaults to false, so you can usually omit it entirely.
Controlled: let the app own it
Pass open and onOpenChange together when something outside the primitive needs to read or drive the state.
import { useState } from "@rbxts/react";import { Dialog } from "@lattice-ui/react-dialog";
export function ConfirmPurchaseDialog(props: { productId: string }) { const [open, setOpen] = useState(false);
// Open it in response to something elsewhere in the app. // The dialog never opens or closes unless `open` says so.
return ( <Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Trigger asChild> <textbutton Text="Buy" Size={UDim2.fromOffset(80, 36)} /> </Dialog.Trigger> {/* ... */} </Dialog.Root> );}In controlled mode the primitive does not update its own state: it calls onOpenChange(true)
and waits for you to feed open={true} back. Ignoring the callback means nothing happens — which is
the leverage controlled mode gives you.
The same shape applies to selection primitives:
import { useState } from "@rbxts/react";import { Tabs } from "@lattice-ui/react-tabs";
export function ProfilePanels() { const [tab, setTab] = useState("overview");
return ( <Tabs.Root value={tab} onValueChange={setTab}> {/* ...triggers and panels... */} </Tabs.Root> );}How it behaves under the hood
useControllableState decides which mode it is in by a single rule. If the value prop is defined,
it is controlled. Otherwise it is uncontrolled. That has a few consequences worth knowing:
- The change callback fires in both modes, so you can observe an uncontrolled primitive without taking it over.
- In controlled mode internal state is never written, so the rendered state can never drift from app state.
- The callback fires only when the value actually changes — setting the same value is a no-op.
| Prop | Type | Description |
|---|---|---|
| value (open/checked) | T | undefined | When defined, the primitive is controlled and renders exactly this value. |
| defaultValue (defaultOpen/...) | T | Initial value for uncontrolled mode. Ignored once a controlled value is supplied. |
| onChange (onOpenChange/...) | (next: T) => void | Called with the next value whenever it changes, in both modes. |
Pick controlled or uncontrolled and stay there for the component’s life. Flipping open between undefined and a real boolean switches ownership models mid-flight. If you need control conditionally, always pass a defined value and a callback.
Passing open without onOpenChange, or never updating the state, leaves the primitive frozen: the trigger fires the callback but open never changes. In controlled mode, wiring the callback back into the value is mandatory.
When to control state
- Multiple surfaces need to coordinate — opening one panel closes another.
- You need to gate or veto a change, like confirming before switching tabs.
- The state must be persisted, restored or synced from outside the tree.
When to leave it uncontrolled
- The interaction is self-contained — a help dialog, a local accordion, a standalone toggle.
- Nothing outside the primitive needs to know or influence the value.
Uncontrolled is less code and harder to get wrong. Reach for controlled only when the app genuinely needs the leverage.