State

Controlled and uncontrolled components, and which ones need a state entity you own.

Rust
1let name = cx.new(|cx| InputState::new(cx));

HeroGPUI components manage state two ways. Most hold their own value when you let them, and report changes through callbacks. Text, number, date and time inputs work the other way: they never keep the value internally, and hand you a state entity to hold instead. Which pattern a component uses is the single thing worth learning before writing a form.

Controlled and uncontrolled

Pass the controlled prop and the component renders your value. Leave it unset and the component keeps the value itself, seeded from the matching default_*.

Rust
1let agreed = cx.new(|_| false);
2
3// Controlled: you hold the value, the component reports changes.
4Checkbox::new("terms").is_selected(*agreed.read(cx))
5
6// Uncontrolled: the component holds it, seeded from default_*.
7Checkbox::new("terms").default_selected(true)

The pairs are is_selected/default_selected, is_open/default_open, selected_key/default_selected_key, expanded_keys/default_expanded_keys, and value/default_value. The full list per component is in llms.txt.

An uncontrolled value lives under the id

Uncontrolled state lives in Window::use_keyed_state, keyed on the component's id. That is why Popover, Accordion and Tooltip take an id even though they render no label from it: it is what tells two instances apart.

Rust
1// Two uncontrolled popovers must not share an id: the keyed state is
2// the id, so both would open together.
3Popover::new(trigger).id("row-1-actions")
4Popover::new(trigger).id("row-2-actions")

One id, one value

Two uncontrolled components that share an id share their state. If a list renders the same component per row, derive the id from the row key.

Components that hand you a state entity

Text, number, date and time inputs do not keep their value internally at all. Their value is a GPUI Entity you construct and own, because it is the thing you read on submit and the thing two components share when they edit the same value:

Rust
1// Built once, in your view's constructor -- never per frame.
2let name = cx.new(|cx| InputState::new(cx));
3
4// ...then handed to the field on every render.
5TextField::new(name.clone()).label("Name").is_required(true)

The six are InputState (Input, TextField, SearchField, TextArea), NumberState (NumberField), OtpState (InputOTP), CalendarState (Calendar, DatePicker), DateRangeState (RangeCalendar, DateRangePicker) and TimeState (TimeField).

Build it once

Build the entity in your view's constructor, not inside render. A fresh entity every frame is a field that forgets what was typed into it.

For these, the constructor is the uncontrolled seed — there is no default_value to set, because the entity already holds one:

Rust
1let name = cx.new(|cx| InputState::with_value(cx, "Ada"));

The others are NumberState::with_value, OtpState::with_length, CalendarState::with_selected, DateRangeState::with_range and TimeState::with_value. Read the current value back through the entity:

Rust
1let typed = name.read(cx).value().to_owned();

Callbacks

Each change callback receives the new value with the window and the app. The value's type follows the component: bool for a checkbox, f64 for a number field, usize for pagination, &str for text, Option<Date> for a calendar. Anything a callback captures must be Arc-cloned: it outlives the frame that built it.

Callback names mirror the documented interaction: on_press starts an action, on_close answers a dismissal, on_change reports a new value. The component reference names each one.