Reactive state (guise::reactive)
A small, React-flavored layer over gpui's reactivity. gpui already has
observable entities and globals; reactive wraps them in a familiar API:
Signal<T>— an observable state cell (React'suseStatevalue).Binding<T>— a two-way connection to a value (SwiftUI'sBinding); see Bindings.provide/use_context— the Context/Provider pattern.use_state/watch/use_memo/use_effect— hook-style helpers.
Everything is in the prelude.
Signal
A clonable handle to a piece of state. All clones share one backing cell, so
passing a Signal around — or providing it as context — gives every holder the
same live value. Writes notify observers.
let count = Signal::new(cx, 0i32); // cx: &mut App or &mut Context<_>
count.get(cx); // T: Clone — copy the value out
count.read(cx); // &T — borrow it
count.set(cx, 5); // replace + notify
count.update(cx, |n| *n += 1); // mutate in place + notify
| Method | Signature | Notes |
|---|---|---|
Signal::new(cx, value) |
&mut App |
create |
get(cx) |
&App -> T |
requires T: Clone |
read(cx) |
&App -> &T |
borrow |
set(cx, value) |
&mut App |
replace, notifies |
set_if_changed(cx, value) |
&mut App |
replace + notify, unless equal — then nothing happens (T: PartialEq) |
update(cx, f) |
&mut App |
f: FnOnce(&mut T), notifies |
binding() |
-> Binding<T> |
the whole signal as a two-way binding |
lens(get, set) |
-> Binding<U> |
one field as a binding |
entity() |
-> &Entity<T> |
for manual cx.observe |
cx is &mut App, but a &mut Context<V> derefs to it, so you can call these
from any view method or event handler.
Bindings
The macOS-style value-binding story, in three parts: a Signal is
the store, a Binding<T> is the connection — a getter and a setter
over App — and a component's .bind(...) / X::bind(...) is the
wiring. Once wired, the value flows both ways with no hand-written change
handlers:
- down — the component reads the current value through the binding (or adopts the signal's value at bind time), and every signal write repaints it;
- up — a user edit writes back through the setter, which lands in
set_if_changedand notifies every other observer.
Equality guards on both directions make an echoed write a no-op, so the round trip terminates instead of looping.
Binding
Cheap to clone (both accessors are Rc-shared) and 'static, so element
closures (.on_click, .hover) can capture it.
| Method | Notes |
|---|---|
Binding::new(get, set) |
from raw accessors — Fn(&App) -> T + Fn(&mut App, T) |
get(cx) |
read the current value |
set(cx, value) |
write a new value |
map(from, into) |
bidirectional transform, e.g. Binding<f64> ⇄ Binding<String>; from converts on read, into back on write |
Binding::constant(value) |
read-only over a fixed value; writes are a no-op (disabled or demo states) |
You rarely call Binding::new yourself — build one from a signal:
let dark = use_state(cx, false);
Switch::new("dark-mode").bind(dark.binding()); // the whole signal
let settings = use_state(cx, Settings::default()); // one field (a lens)
Checkbox::new("mute").bind(settings.lens(|s| s.muted, |s, v| s.muted = v));
Signal::binding requires T: Clone + PartialEq; lens projects a
Signal<T> to a Binding<U> with a getter and a field setter, and skips the
notify when a write leaves the field unchanged.
Wiring components
The two component patterns bind differently — see Inputs → Binding inputs for the per-component surface:
- Controlled builders (
Checkbox,Switch,Rating, …) take aBindingvia.bind(...); it overrides the plain value setter, and user actions write back through it before running anyon_change. - Stateful entities (
TextInput,Slider,Editor, …) own their state, so they bind to theSignalitself, once, after creation:X::bind(&entity, &signal, cx). Under the hood that's acx.subscribe(entity events → signal) plus acx.observe(signal writes → entity).
Worked example: one Signal, two editors
Bind a single Signal<String> to a TextInput and an Editor at once.
Type in either — the edit lands in the signal via set_if_changed, the
observer pushes it into the other view, and the equality guard stops the echo
there.
struct Scratch {
source: Signal<String>,
input: Entity<TextInput>,
editor: Entity<Editor>,
}
impl Scratch {
fn new(cx: &mut Context<Self>) -> Self {
let source = use_state(cx, String::from("fn main() {}"));
let input = cx.new(|cx| TextInput::new(cx).label("One-liner"));
TextInput::bind(&input, &source, cx);
let editor = cx.new(|cx| Editor::new(cx).language(Language::Rust).rows(6));
Editor::bind(&editor, &source, cx);
watch(cx, &source); // only needed if this view renders the value too
Scratch { source, input, editor }
}
}
impl Render for Scratch {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
Stack::new()
.child(self.input.clone())
.child(self.editor.clone())
.child(Text::new(format!("{} chars", self.source.read(cx).len())).dimmed())
}
}
Programmatic writes work the same way: source.set(cx, "reset".into())
updates both views, no component-specific code required.
For collections there's a dedicated component:
DataView observes a Signal<Vec<T>> and
repaints the list/grid on every write — filtering and sorting are render-time
projections, so the source vector is never touched.
Context / Provider
Share a value across the whole app, keyed by its Rust type — the gpui global idiom (one value per type). This is how a child reads state it wasn't handed.
provide(cx, count.clone()); // <Context.Provider value={count}>
let count = use_context::<Signal<i32>>(cx).unwrap(); // useContext
| Function | Notes |
|---|---|
provide::<T>(cx, value) |
set/replace the provided value of type T |
use_context::<T: Clone>(cx) -> Option<T> |
cloned read |
use_context_ref::<T>(cx) -> Option<&T> |
borrowed read |
has_context::<T>(cx) -> bool |
presence check |
One value per type. To provide several values of the same shape, wrap each
in a distinct newtype (struct UserId(Signal<u64>)).
Hooks
let count = use_state(cx, 0i32); // = Signal::new
watch(cx, &count); // re-render this view when `count` changes
let label = use_memo(cx, &count, |n| format!("Count: {n}")); // derived signal
use_effect(cx, &count, |n, _cx| println!("count -> {n}")); // side effect
watch is the wiring behind a component "subscribing" to state. Call it once
per signal in a view's constructor (where cx is the view's Context).
use_memo(cx, &source, f)— derived state (React'suseMemo). Returns a newSignal<U>that recomputesf(&T) -> Uon everysourcechange;watchthe returned signal like any other.use_effect(cx, &source, f)— runf(&value, &mut App)with the current value wheneversourcechanges (React'suseEffectwith one dependency).
use_effect clones the value out of the signal before running
your closure, so the effect may freely read or write any signal — including
source itself. A write to source re-triggers the effect, so guard it
with a condition (or the effect loops forever); to derive a value from the
source, use use_memo instead.
Forms
FormState is a small, unit-testable store of field values, validators, and the
errors from the last validation — keyed by field name. Make it reactive by
holding it in a Signal (that's what use_form returns) and mutating through
signal.update.
use guise::reactive::validators;
let form = use_form(cx, FormState::new()
.field("email", "")
.validator("email", validators::email())
.field("name", "")
.validator("name", validators::required()));
// in a submit handler:
form.update(cx, |f| {
if f.validate() {
// f.value("email"), f.value("name") …
}
});
// reading errors while rendering:
let email_error = form.read(cx).error("email"); // Option<&str>
FormState methods: new(), field(name, initial), validator(name, v),
value(name), set(name, value), validate_field(name), validate(),
error(name), is_valid(). Built-in validators: required(), min_len(n),
email(). A Validator is Box<dyn Fn(&str) -> Option<String>>, so custom
rules are just closures.
Worked example: two views, one shared Signal
The pattern is exactly React's "lift state up, share via context": create a
Signal at the root, provide it, and let descendants read it back with
use_context and watch it.
use gpui::prelude::*;
use gpui::{Context, Entity, Window, IntoElement};
use guise::prelude::*;
// A child that never receives the signal directly — it reads it from context.
struct Counter { count: Signal<i32> }
impl Counter {
fn new(cx: &mut Context<Self>) -> Self {
let count = use_context::<Signal<i32>>(cx).expect("counter provided");
watch(cx, &count); // re-render when it changes
Counter { count }
}
}
impl Render for Counter {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
Text::new(format!("Count: {}", self.count.get(cx))).bold()
}
}
struct App { count: Signal<i32>, counter: Entity<Counter> }
impl App {
fn new(cx: &mut Context<Self>) -> Self {
let count = use_state(cx, 0);
provide(cx, count.clone()); // share it
let counter = cx.new(Counter::new);
App { count, counter }
}
}
impl Render for App {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
Stack::new()
.child(self.counter.clone()) // reflects the shared value
.child(Button::new("inc", "+").on_click(
cx.listener(|this, _, _, cx| this.count.update(cx, |n| *n += 1)),
))
}
}
Press + and the Counter view updates, even though it only ever knew about the
Signal through context. Mutating the signal notifies every watcher.
How it maps to gpui
Signal<T>is a thin wrapper overEntity<T>;updatecallsentity.update(cx, |v, cx| { …; cx.notify() }).Binding<T>is a pair ofRc'd closures overApp— no entity of its own.X::bind(entity, signal, cx)iscx.subscribe(component events → signal) pluscx.observe(signal writes → component), both detached, with equality guards at each end.watchiscx.observe(signal.entity(), |_, _, cx| cx.notify()).detach().provide/use_contextstore the value in a typed gpui global.
If you outgrow this layer, drop down to the entities and globals directly — it's the same machinery.