Skip to content

Guide Recommended Practices

MeowLynxSea edited this page Jun 18, 2026 · 7 revisions

Recommended practices

The rules every app converges on. Each one corresponds to a real failure mode seen in the demos or in the headless factory contracts.

1) One Entity<T> clone per move closure

let inc = state.counter.clone();
let dec = state.counter.clone();
let reset = state.counter.clone();

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

A single clone cannot be shared across multiple closures — the first one to consume it moves it out.

2) Mutate, then cx.notify()

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

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

3) cx.entity().clone() for root-entity callbacks

For everything owned by the root Entity<MyApp>:

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)

Cloning cx.entity() is cheap (it's an Arc bump). Each move closure that needs it clones once at construction.

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

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

  • Append-only data → use the row index.
  • Mutable data → use the row's primary key (id, uuid, path).
text_input(format!("row-email-{ix}")).render(cx, window)
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:

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

The modal and toast host are siblings of the scroll root. Priority 2 keeps the modal above page content; priority 3 keeps toasts above the modal.

6) Render closures are pure

The following belong in event handlers, not in Render::render:

  • Spawning tasks (cx.spawn)
  • File I/O
  • Network calls
  • Mutating any global that doesn't go through entity.update + cx.notify()

The exception: yororen_ui::theme::install(cx, …) is legal in render because it's idempotent and cheap. theme_showcase calls it every frame.

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) Context<T> derefs to App automatically

Headless factories take &mut App. Inside Render::render you have &mut Context<MyApp>. Context<T>: DerefMut<Target = App>, so passing cx directly works:

fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
    button("save", cx)        // cx coerces to &mut App via DerefMut
        .on_click(...)
        .render(cx)            // same here
}

When you need to let-bind a state-minting call (e.g. ModalState::new(...)), use &mut **cx explicitly:

let modal_state = ModalState::new(&mut **cx);
let popover_state = PopoverState::new(&mut **cx);

This is the v0.3 Context → App pattern. Clippy sees it as a redundant auto-deref; the explicit form is intentional.

9) Prefer .render(cx) over .apply(div)

API Returns What it does
props.apply(div) Stateful<Div> Sets id, focus tracking, click handler. No visual feedback.
props.render(cx) Stateful<Div> (or AnyElement for inputs) Looks up the registered renderer, calls compose, layers a11y on top.

Default rule: use .render(cx). It picks up the active renderer and theme.

Reach for .apply(div) only when:

  • You need caller-owned visuals with no theme.
  • You're writing a custom gpui::Element (the MaterialRippleElement in layers_demo 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. For a hybrid, write a new renderer that does both.

For "Next theme" toolbar buttons, swap the theme (yororen_ui::theme::install(cx, theme)), not the renderer — that's the cheap path.

See also

Clone this wiki locally