Skip to content

Forms

gpui-starter ships a working registration form that ties together three crates: gpui-form generates form state and UI bindings from derive macros, koruma provides composable validation rules, and es-fluent localizes every label and error message. The form lives in src/features/pages/form_page.rs.

This page walks through the full setup: defining a form model, wiring input subscriptions, rendering the UI, and handling submit with inline errors. For the deeper tutorial on writing custom validators, see the form validation in Rust with koruma blog post.

A single struct with derive macros handles the setup:

#[derive(Clone, Debug, Default, EsFluentVariants, GpuiForm, Koruma, KorumaAllFluent)]
#[fluent_variants(keys = ["description", "label"])]
#[gpui_form(koruma(fluent))]
pub struct RegistrationForm {
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(NonEmptyValidation::<_>)]
pub name: String,
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(EmailValidation::<_>)]
pub email: String,
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(NonEmptyValidation::<_>)]
pub password: String,
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(PhoneNumberValidation::<_>)]
pub phone: String,
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(UrlValidation::<_>)]
pub website: String,
}

Each derive serves a specific purpose:

Derive What it generates
GpuiForm RegistrationFormFormFields (entity holders for each input), RegistrationFormFormValueHolder (typed value storage), RegistrationFormFormComponents (factory methods to create input entities)
Koruma A validate() method on the value holder that runs all field rules and returns Result<(), ValidationErrors>
EsFluentVariants RegistrationFormLabelVariants and RegistrationFormDescriptionVariants enums for typed message lookup
KorumaAllFluent Fluent-backed error messages for each validator, auto-localized to the current locale

The validators come from koruma-collection, which ships two categories:

Collection validators (in koruma_collection::collection):

Validator What it checks When to use
NonEmptyValidation Field is not empty or whitespace Required fields: name, password

Format validators (in koruma_collection::format):

Validator What it checks Example valid input
EmailValidation Standard email format user@example.com
PhoneNumberValidation North American phone format (555) 123-4567
UrlValidation Valid URL with scheme https://example.com

In the #[koruma(...)] attribute, validators are written as bare paths (NonEmptyValidation::<_>); chain option setters directly on the path (for example .min(3)) when a validator needs configuration. The #[koruma(...)] attribute on each field is all the wiring you need.

The #[fluent_variants(keys = ["description", "label"])] attribute tells es-fluent to generate two enums: one for field labels and one for descriptions. These map directly to keys in your .ftl files.

From i18n/en/gpui-starter.ftl:

registration_form_label_variants-name = Full Name
registration_form_label_variants-email = Email
registration_form_label_variants-password = Password
registration_form_label_variants-phone = Phone
registration_form_label_variants-website = Website
registration_form_description_variants-name = Enter your full name.
registration_form_description_variants-email = We'll never share your email with anyone else.
registration_form_description_variants-password = Choose a strong password.
registration_form_description_variants-phone = Format: (555) 123-4567
registration_form_description_variants-website = Optional. Your personal or company site.

Because KorumaAllFluent is derived alongside EsFluentVariants, validation error messages also resolve through the Fluent system. A missing name field produces “Enter your full name.” in English or the translated equivalent in the active locale. See the internationalization docs for how to add new locales.

GpuiForm generates a RegistrationFormFormFields struct that holds an Entity<InputState> per field. Under gpui-form 0.6 the generated member and factory names are the RAW field names (name, not name_input). You create these in the page constructor and subscribe to change events via the value_change shape helper, which normalizes component events into ValueChange::Set/Clear/Unchanged:

use gpui_form::runtime::shape::{ValueChange, value_change};
use gpui_form_collection::input::Input as FormInput;
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let current_data = RegistrationFormFormValueHolder::default();
// Create input entities via the generated factory methods (raw field names)
let name = cx.new(|cx| RegistrationFormFormComponents::name(window, cx));
let email = cx.new(|cx| RegistrationFormFormComponents::email(window, cx));
// ... one per field
// Subscribe to changes to keep current_data in sync. The 0.6 value holder
// stores plain String fields, so a cleared input maps to the empty string.
let _subscriptions = vec![
cx.subscribe(
&name,
|this: &mut FormPage, state: Entity<InputState>, event: &InputEvent, cx| {
match value_change::<FormInput, String>(state.read(cx), event) {
ValueChange::Set(value) => this.current_data.name = value,
ValueChange::Clear => this.current_data.name = String::new(),
ValueChange::Unchanged => return,
}
},
),
// ... one subscription per field
];
Self {
current_data,
fields: RegistrationFormFormFields {
name,
email,
// ... rest
},
agree_terms: false,
submitted: false,
touched: false,
_subscriptions,
}
}

The touched field is important. Validation only runs after the user clicks submit. This avoids showing errors on fields the user has not interacted with yet. It is a common UX pattern in form libraries.

Use v_form() and field() from gpui_component to build the layout. Each field gets a localized label, a required marker, a description, and an error slot:

v_form()
.label_width(px(160.))
.child(
field()
.label(crate::i18n::localize_message(
&RegistrationFormLabelVariants::Name,
))
.required(true)
.description_fn({
let error = error_for("name");
let description = crate::i18n::localize_message(
&RegistrationFormDescriptionVariants::Name,
);
move |_, _| {
div()
.flex()
.flex_col()
.gap_1()
.child(div().child(description.clone()))
.when_some(error.clone(), |el, err| {
el.child(div().text_color(danger).text_xs().child(err))
})
}
})
.child(Input::new(&self.fields.name)),
)

The description_fn closure captures both the help text and any validation error for that field. When touched is false, error_for() returns None and the user sees only the description. After submit, errors appear inline below the help text in the danger color.

Validation produces a ValidationErrors struct with typed accessors for each field:

let error_for = |field_name: &str| -> Option<String> {
validation_errors.as_ref().and_then(|e| {
let errs: Vec<String> = match field_name {
"name" => e.name().all().map(|m| crate::i18n::localize_message(&m)).collect(),
"email" => e.email().all().map(|m| crate::i18n::localize_message(&m)).collect(),
// ... other fields
_ => Vec::new(),
};
if errs.is_empty() { None } else { Some(errs.join("\n")) }
})
};

Each call like e.name() returns an iterator over that field’s validation failures (owned Fluent messages under koruma 0.11). localize_message converts each failure to a string in the current locale. Multiple errors on the same field are joined with newlines.

The submit button runs validation, checks the terms checkbox, and pushes a notification:

Button::new("submit")
.primary()
.label(crate::i18n::localize("form_submit", None))
.on_click(cx.listener(|this, _, window, cx| {
this.touched = true;
let valid = this.current_data.validate().is_ok();
if valid && this.agree_terms {
this.submitted = true;
window.push_notification(
crate::i18n::localize("form_notification_submitted", None),
cx,
);
} else if valid && !this.agree_terms {
window.push_notification(
crate::i18n::localize("form_notification_agree_terms", None),
cx,
);
} else {
window.push_notification(
crate::i18n::localize("form_notification_fix_errors", None),
cx,
);
}
cx.notify();
}))

There are three outcomes:

  1. Valid and terms accepted: mark submitted = true, show success banner, push notification.
  2. Valid but terms not accepted: push a notification asking the user to agree to terms.
  3. Invalid: push a notification to fix errors. The next render cycle picks up touched = true and displays inline errors.

The reset button clears all input values, resets touched and submitted, and calls cx.notify() to re-render:

fn on_reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.current_data = RegistrationFormFormValueHolder::default();
self.fields.name.update(cx, |s, cx| s.set_value("", window, cx));
// ... clear other fields
self.agree_terms = false;
self.submitted = false;
self.touched = false;
cx.notify();
}

If the built-in validators from koruma-collection do not cover your needs, you can write custom validators by implementing koruma’s trait. The form validation tutorial covers this in detail with a worked example of a password strength validator.

  • Internationalization: how Fluent message files work and how to add new languages
  • Notifications: the push_notification API used in form submission
  • Architecture: entity ownership patterns that apply to form state management
  • Testing: strategies for testing form validation logic in isolation