Back to FAQ Features

How do I add form validation in a GPUI Rust app?

gpui-starter uses gpui-form derive macros with koruma validation rules for type-safe, localized form handling. Covers built-in validators, custom rules, inline errors, and the touched pattern.

gpui-starter validates forms through two crates: gpui-form generates form state and UI bindings from derive macros on your struct, and koruma runs composable validation rules against the field values. Error messages go through Fluent so they follow the active locale without extra work.

Defining a form with validation

One struct with derive macros does the wiring. Each field gets a component type from gpui-form and a validator from koruma-collection:

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

Four derives on one struct, each with a specific job:

DeriveWhat it produces
GpuiFormEntity holders, typed value storage, factory methods for input entities
KorumaA validate() method that runs all field rules and returns Result<(), ValidationErrors>
EsFluentVariantsTyped enums for looking up labels and descriptions from Fluent
KorumaAllFluentFluent-backed error messages for every validator, localized to the current locale

Built-in validators

koruma-collection ships validators in two categories:

Collection validators (koruma_collection::collection):

ValidatorChecksUse for
NonEmptyValidationField is not empty or whitespace-onlyRequired fields: name, password

Format validators (koruma_collection::format):

ValidatorChecksExample valid input
EmailValidationStandard email format[email protected]
PhoneNumberValidationNorth American phone format(555) 123-4567
UrlValidationValid URL with schemehttps://example.com

All validators use the builder pattern. You can chain configuration methods on the builder to customize strictness or error messages.

How validation and rendering connect

The GpuiForm derive generates a RegistrationFormFormFields struct that holds an Entity<InputState> per field. You create these in the page constructor and subscribe to change events to keep a current_data struct in sync.

Validation runs on submit, not on every keystroke. A touched boolean tracks whether the user has attempted to submit. Errors only render when touched is true, so the form stays clean until the user acts.

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();
}))

Three outcomes: valid with terms accepted (success), valid without terms (reminder notification), or invalid (fix-errors notification plus inline error display on the next render).

Inline errors per field

Validation produces a ValidationErrors struct with typed accessors for each field. You extract errors and render them next to the relevant input:

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().iter().map(crate::i18n::localize_message).collect(),
"email" => e.email().all().iter().map(crate::i18n::localize_message).collect(),
_ => Vec::new(),
};
if errs.is_empty() { None } else { Some(errs.join("\n")) }
})
};

localize_message converts each validation failure to a Fluent string in the current locale. Multiple errors on the same field are joined with newlines.

Custom validators

If the built-in validators do not cover your use case, you can implement koruma’s validation trait directly. The form validation tutorial walks through building a password strength validator from scratch. The koruma deep dive explains the trait interface and how to compose custom rules with the built-in ones.

Localized labels and descriptions

The #[fluent_variants(keys = ["description", "label"])] attribute generates enums that map to keys in your .ftl files:

registration_form_label_variants-name = Full Name
registration_form_description_variants-name = Enter your full name.

Because KorumaAllFluent is derived alongside EsFluentVariants, validation error messages also resolve through Fluent. A missing name field produces the localized help text, not a hard-coded English string. See the i18n docs for how to add new locales.

Where to go next