Latticeguides

Controlled and uncontrolled state

The controlled/uncontrolled pattern every stateful primitive shares, and when to own the state.

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:

ConceptControlledUncontrolled defaultChange callback
Open state (overlays)opendefaultOpenonOpenChange
Selection valuevaluedefaultValueonValueChange
Boolean statecheckeddefaultCheckedonCheckedChange

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.

Uncontrolled dialog
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.

Controlled dialog
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:

Controlled tabs
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.
PropTypeDescription
value (open/checked)T | undefinedWhen defined, the primitive is controlled and renders exactly this value.
defaultValue (defaultOpen/...)TInitial value for uncontrolled mode. Ignored once a controlled value is supplied.
onChange (onOpenChange/...)(next: T) => voidCalled with the next value whenever it changes, in both modes.

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.