Skip to content

Guide Recommended Practices

MeowLynxSea edited this page Jun 17, 2026 · 7 revisions

Recommended practices

Eight rules every v0.3 app converges on. They are not aesthetic preferences — each one corresponds to a real failure mode in the six live demos (counter, gallery_demo, inputs_demo, layers_demo, theme_showcase, variant_showcase) or in the headless factory contracts.

1) Clone the entity once per on_* closure

Every move closure that touches your app state needs its own Entity<T> clone. The closure owns the clone; the original entity handle stays in the global. A single state.counter.clone() per closure is cheap (it's an Arc bump).

// Counter — three closures, three clones.
let inc_entity = state.counter.clone();
let dec_entity = state.counter.clone();
let reset_entity = state.counter.clone();

button("dec", cx)
    .on_click(move |_ev, _w, cx| {
        dec_entity.update(cx, |c, cx| { c.value -= 1; cx.notify(); });
    })
    .render(cx)
    .child("-")

Don't try to share a single clone across multiple closures — the first one to consume it will move it out.

2) Mutate, then cx.notify()

gpui-ce 0.3 does not auto-notify on Entity::update. The &mut Context inside update is what triggers a re-render.

entity.update(cx, |state, cx| {
    state.value += 1;
    cx.notify();         // <-- required
});

Without cx.notify(), the entity's data changes but the window won't repaint.

3) cx.entity().clone() is the canonical closure pattern

For everything owned by the root Entity<MyApp> (most apps), the canonical way to capture it inside an on_change / on_click is:

text_input("email")
    .placeholder("you@example.com")
    .on_change({
        let entity = cx.entity();
        move |new: &str, _w, cx| {
            entity.update(cx, |s, _cx| s.email = new.to_string());
        }
    })
    .render(cx, window)

Why cx.entity() and not state.global_clone():

  • It's the only way to get an Entity<MyApp> from inside a Context<MyApp> render closure.
  • Cloning it is cheap (Entity is internally Arc).
  • Each move closure that needs it must clone it once at construction time.

The inputs_demo has all seven text inputs wired this way. Copy from crates/yororen-ui-demos/inputs_demo/src/inputs_app.rs.

4) Stable .id(...) for stateful items

Every stateful child in a list, virtualized view, or any tree that can be reordered needs a stable id derived from your data:

  • Append-only data → use the row index as the id.
  • Mutable data → use the row's primary key (id, uuid, path).
// Append-only feed — index is fine.
text_input(format!("row-email-{ix}")).render(cx, window)

// Mutable user list — use the user id.
list_item(format!("user-row-{}", user.id), &user.name, cx).render(cx)

Without stable ids, gpui recycles element state across positions and your input focus / caret / selection get scrambled on every reorder.

5) Render overlays at the scroll root, not inside a section

A modal that lives inside a scrollable column will be clipped and won't receive Escape reliably. The shape is:

root
├── scroll root (.id("…").overflow_y_scroll())
│   └── page content
├── modal (wrapped in gpui::deferred(...).with_priority(2))
└── toast host (wrapped in gpui::deferred(...).with_priority(3))

The modal and toast host are siblings of the scroll root, not children of it. Priority 2 keeps the modal above page content; priority 3 keeps toasts above the modal (used by the gallery_demo notification host).

6) No side effects in Render::render

Render closures must be pure with respect to gpui's redraw cycle. The following belong in event handlers, not in render:

  • Spawning tasks (cx.spawn)
  • File I/O
  • Network calls
  • Mutating any global that doesn't go through entity.update
    • cx.notify()
  • Calling theme::install(cx, …) is legal (the theme_showcase does it every frame) because it's idempotent and cheap.

7) Virtualize long lists

For lists with more than ~50 rows, use virtual_list (variable height) or uniform_virtual_list (fixed height, faster). The demos render 10 000 items without breaking a sweat.

Avoid rendering a single "giant item" that contains a huge subtree if you can split it into rows — virtualization clips what's offscreen, but it can't make a single visible item cheap.

8) Use &mut *cx for entity minting from Context<T>

Several headless factories need &mut App (to mint a fresh FocusHandle or an Entity<XxxState>):

  • button(id, cx)
  • label(id, text, cx)
  • heading(id, level, text, cx)
  • icon(id, source, cx)
  • badge(id, text, cx)
  • tag(id, label, cx)
  • divider(id, cx)
  • card(id, cx)
  • panel(id, cx)
  • switch(id, cx), checkbox(id, cx), radio(id, cx)
  • empty_state(id, cx)
  • disclosure(id, title, cx)
  • list_item(id, title, cx)
  • form(id, cx), form_field(id, name, cx)
  • ModalState::new(&mut *cx), PopoverState::new(&mut *cx), SelectState::new(&mut *cx), ComboBoxState::new(&mut *cx), DropdownMenuState::new(&mut *cx), TooltipState::new(&mut *cx)

Inside Render::render you only have &mut Context<MyApp>. Context<T>: DerefMut<Target = App>, so:

// Inside Render::render(_w, cx: &mut Context<MyApp>):
let modal_state = ModalState::new(&mut *cx);   // <-- inline
let btn = button("save", cx).render(cx);       // cx still borrowed

Inline the &mut **cx at the call site — never store it in a let binding, because that ties up the borrow of cx and blocks the next factory call that also needs &mut App.

9) Prefer headless factory + .render(cx) over apply(div)

Every headless factory exposes both .apply(div) and .render(cx):

API What it returns What it does
props.apply(div) Stateful<Div> Sets id, track_focus, on_click. No visual feedback.
props.render(cx) Stateful<Div> (or AnyElement for inputs) Looks up the registered renderer, calls compose, then wires the same a11y callbacks on top.

Default rule: use .render(cx). It picks up the active renderer (default, brutalism, or your custom) and the active theme.

Reach for .apply(div) only when:

  • You need caller-owned visuals (no theme at all).
  • You're writing a custom gpui::Element (the MaterialRippleElement in crates/yororen-ui-demos/layers_demo/src/material_button.rs does this — it still calls headless::button(id, cx).on_click(...).apply(...) internally to keep the focus + click wiring).

10) One renderer per (Marker, dyn Trait) slot

If you register MyButtonRenderer for the Button marker, the previous TokenButtonRenderer is overwritten. There's no layering. If you want a hybrid, write a new renderer that does both.

theme_showcase swaps the theme (not the renderer) at runtime via theme::install(cx, theme) — that's the cheap path for "Next theme" toolbar buttons.

See also

  • Guide-Composing-UI — concrete patterns built from these rules
  • Guide-AnimationAnimatedVisibility and the composite lifecycle
  • codex-skills/yororen-ui-state-inputs §8 — verifying input wiring
  • codex-skills/yororen-ui-recipes §7 — composition rules

Clone this wiki locally