State
Controlled and uncontrolled components, and which ones need a state entity you own.
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_*.
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.
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:
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 insiderender. 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:
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:
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.