Data Binding

This section documents the current Vaadin release line — Vaadin 24 LTS / 25.x, Java 17+, Spring Boot 3 / Jakarta EE 10 — as published at the official Vaadin documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. Flow (server-side Java) is the authoring style used throughout, with Hilla / React shown where it differs; Vaadin 7 and the pre-Flow architecture appear only as migration contrast.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vaadin ships major releases roughly twice a year and its ecosystem iterates.

This section’s bibliography lists the reference material consulted while preparing these pages.

Binder<T> is Vaadin Flow’s bridge between a form’s input components and a typed business object: it loads values from the object into the fields, runs conversion and validation as the user types, and writes the values back only when they are valid. This page covers the API defined in Binding Data to Forms. For the fields themselves see Input Components, and for assembling a full editor see Forms, CRUD and Master-Detail.

The Binder

A Binder<T> is created for one bean type and shared by every field in the form. The quickest way to wire fields is bindInstanceFields(this), which matches each HasValue member variable to a bean property of the same name by reflection.

public class Person {
    private String fullName;
    private String email;
    private int age;
    private Address address = new Address();
    // getters and setters
}

public class PersonForm extends FormLayout {

    private final TextField fullName = new TextField("Full name");
    private final EmailField email = new EmailField("Email");
    private final IntegerField age = new IntegerField("Age");

    private final Binder<Person> binder = new Binder<>(Person.class);

    public PersonForm() {
        add(fullName, email, age);
        binder.bindInstanceFields(this);   // fullName -> "fullName", email -> "email", age -> "age"
    }
}

bindInstanceFields skips fields that are already bound, so bindings that need a converter or a validator are declared manually first and the call then fills in the rest. A member variable with no matching property, and no manual binding, makes the call throw.

Manual bindings with forField

forField(field) starts a fluent binding: chain asRequired, withConverter and withValidator, then close with bind(getter, setter). Passing null as the setter makes the binding read-only.

binder.forField(fullName)
        .asRequired("Full name is required")
        .withValidator(name -> name.length() >= 2, "At least two characters")
        .bind(Person::getFullName, Person::setFullName);

binder.forField(email)
        .bind(Person::getEmail, Person::setEmail);

bind also accepts a property name (binder.bind(fullName, "fullName")), but the method-reference form is checked at compile time and is the recommended style for anything past a trivial form.

Converters

A field’s value type often differs from the property type — a TextField produces String, the bean stores int. withConverter sits between them, converting presentation to model as the user types and back again on load. Vaadin ships common converters such as StringToIntegerConverter; Validating & Converting User Input describes the full set.

TextField ageField = new TextField("Age");

binder.forField(ageField)
        .withConverter(new StringToIntegerConverter("Enter a whole number"))
        .withValidator(new IntegerRangeValidator("Age must be 0-150", 0, 150))
        .bind(Person::getAge, Person::setAge);

Write a custom Converter<PRESENTATION, MODEL> with Converter.from, returning a Result so a failed conversion becomes a field error instead of an exception.

Converter<String, LocalDate> isoDate = Converter.from(
        text -> {
            try {
                return Result.ok(LocalDate.parse(text));
            } catch (DateTimeParseException e) {
                return Result.error("Use the format yyyy-MM-dd");
            }
        },
        LocalDate::toString);

Converters and validators run in declaration order on the way in, and in reverse on the way out, so place a validator before the converter to check the raw text and after it to check the converted value.

Validators and BindingValidationStatus

withValidator adds a rule to a single binding, either as a (value, message) pair or a full Validator<T> returning ValidationResult.ok() / ValidationResult.error(…​). Keep the Binding reference to validate or inspect that one field on demand.

Binder.Binding<Person, String> emailBinding = binder.forField(email)
        .withValidator(new EmailValidator("Not a valid email address"))
        .bind(Person::getEmail, Person::setEmail);

BindingValidationStatus<String> status = emailBinding.validate();
if (status.isError()) {
    status.getMessage().ifPresent(Notification::show);
}

BindingValidationStatus is the per-field result — status (OK, ERROR, UNRESOLVED), message, and the originating Result. The whole-form equivalent is BinderValidationStatus, returned by binder.validate(), which exposes getFieldValidationStatuses() and getBeanValidationErrors(). Route messages to a specific component with withStatusLabel(label), or take full control with withValidationStatusHandler(handler).

Cross-field validation

A rule that spans two fields can live on one binding (revalidate the partner when the other field changes) or on the binder itself as a bean-level validator that runs during writeBean.

DatePicker start = new DatePicker("Start");
DatePicker end = new DatePicker("End");

Binder.Binding<Trip, LocalDate> endBinding = binder.forField(end)
        .withValidator(d -> d == null || start.getValue() == null
                || !d.isBefore(start.getValue()), "End cannot be before start")
        .bind(Trip::getEnd, Trip::setEnd);

start.addValueChangeListener(e -> endBinding.validate());   // re-run the rule when start moves

// bean-level: evaluated on writeBean / writeBeanIfValid, after every field-level rule passes
binder.withValidator(trip -> trip.getStart() == null || trip.getEnd() == null
        || !trip.getEnd().isBefore(trip.getStart()),
        "End cannot be before start");

BeanValidationBinder and Jakarta Bean Validation

BeanValidationBinder<T> extends Binder<T> with the same API but automatically adds a validator for every Jakarta Bean Validation (JSR 380) constraint annotation it finds on the bean, and marks @NotNull, @NotEmpty and @Size(min > 0) fields as required.

public class Person {

    @NotNull
    @Size(min = 2, max = 60, message = "Full name must be 2-60 characters")
    private String fullName;

    @NotNull
    @Email
    private String email;

    @Min(0)
    private int age;

    @Valid                          // cascade validation into the nested bean
    private Address address = new Address();

    // getters and setters
}
BeanValidationBinder<Person> binder = new BeanValidationBinder<>(Person.class);
binder.bindInstanceFields(this);    // constraint validators are attached to each binding automatically

This needs a Bean Validation provider on the classpath. Spring Boot projects add spring-boot-starter-validation; otherwise depend on Hibernate Validator directly, as noted in Binding Beans to Forms.

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
</dependency>

Manually declared converters and validators still apply on top of the constraint-derived ones.

Nested bean properties

Binder reads a dotted path into a nested bean. The string form works directly; the type-safe form supplies lambdas that walk the object graph.

public class Address {
    @NotBlank private String street;
    @Size(min = 4, max = 10) private String postalCode;
    // getters and setters
}
TextField street = new TextField("Street");
TextField postalCode = new TextField("Postal code");

binder.bind(street, "address.street");          // dot path resolves getAddress().getStreet()

binder.forField(postalCode)
        .bind(p -> p.getAddress().getPostalCode(),
              (p, v) -> p.getAddress().setPostalCode(v));

Keep the nested bean instance non-null (initialise it in the parent, as Person does above) so the setter path has something to write to. With @Valid on the nested field, a BeanValidationBinder also enforces the Address constraints.

Loading and saving: readBean, writeBean, writeBeanIfValid

readBean copies the bean into the fields and resets validation state; passing null clears the form. writeBean validates every binding plus any bean-level validator and copies the values back, throwing ValidationException on failure. writeBeanIfValid does the same but returns a boolean instead of throwing.

binder.readBean(person);            // bean -> fields
binder.readBean(null);              // clear the form

try {
    binder.writeBean(person);       // fields -> bean, or throw
    repository.save(person);
} catch (ValidationException e) {
    e.getValidationErrors().forEach(result -> log.warn(result.getErrorMessage()));
}

if (binder.writeBeanIfValid(person)) {
    repository.save(person);
} else {
    Notification.show("Fix the highlighted fields");
}

writeBeanAsDraft(person) copies whatever converts cleanly, skipping validation, for saving a work-in-progress. Java record beans use the immutable pair readRecord / writeRecord instead.

Buffered vs. unbuffered binding

Mode Behaviour

Buffered (default)

Enter with readBean(bean). The fields hold a working copy and the bean is left untouched until writeBean / writeBeanIfValid succeeds. Cancel simply discards the fields. This is the right choice for a dialog with Save and Cancel buttons.

Unbuffered

Enter with setBean(bean). Every edit that passes validation is written straight into the bean — there is no explicit save call. Convenient for always-live editors, but other views may observe half-finished changes; "cancel" means calling readBean with a pristine copy.

// buffered
binder.readBean(person);
saveButton.addClickListener(e -> {
    if (binder.writeBeanIfValid(person)) {
        repository.save(person);
    }
});

// unbuffered
binder.setBean(person);             // valid edits flow into `person` immediately

binder.hasChanges() reports whether the fields differ from what was last read (buffered mode); with setChangeDetectionEnabled(true) it compares against the original values rather than just tracking edits. See Loading & Saving to Business Objects.

StatusChangeListener

addStatusChangeListener fires on every validation run, value change, and on readBean / writeBean — the usual place to enable or disable the Save button.

binder.addStatusChangeListener(event -> {
    boolean saveable = !event.hasValidationErrors() && binder.hasChanges();
    saveButton.setEnabled(saveable);
});

The StatusChangeEvent also reports getBinder() and whether the change came from a write operation, which lets a listener distinguish a user edit from a programmatic reload.

HasValidator

A component can carry its own validation rule by implementing HasValidator<T>. When such a component is bound, Binder automatically picks up getDefaultValidator(), so an intrinsic rule — a custom PhoneField that only accepts digits, say — does not have to be repeated at every binding. The component’s addValidationStatusChangeListener lets it tell the binder to revalidate when its internal constraints change.

public class PhoneField extends CustomField<String> implements HasValidator<String> {

    private final TextField input = new TextField();

    @Override
    protected String generateModelValue() {
        return input.getValue();
    }

    @Override
    protected void setPresentationValue(String value) {
        input.setValue(value == null ? "" : value);
    }

    @Override
    public Validator<String> getDefaultValidator() {
        return (value, ctx) -> value == null || value.matches("\\+?\\d{6,15}")
                ? ValidationResult.ok()
                : ValidationResult.error("Enter 6 to 15 digits");
    }
}

// no withValidator needed -- the field's own rule is applied
binder.forField(new PhoneField()).bind(Person::getPhone, Person::setPhone);

Many built-in Vaadin fields already implement HasValidator, which is how constraints such as an EmailField pattern or a DatePicker min/max participate in binder validation without extra code.

The Binder data flow

flowchart TD bean["Business object (bean / record)"] bean -->|"readBean(bean)"| fields["Form fields (HasValue)"] fields -->|"user edits"| conv["Converter chain
presentation to model"] conv --> val["Validator chain
field-level, HasValidator, bean-level"] val -->|"error"| status["BindingValidationStatus / BinderValidationStatus
StatusChangeListener fires, messages shown"] status --> fields val -->|"valid"| mode{"binding mode?"} mode -->|"buffered: writeBean() / writeBeanIfValid()"| write["Values copied into the bean on save"] mode -->|"unbuffered: setBean(bean)"| live["Each valid edit written into the bean at once"] write --> bean live --> bean

In Hilla / React: forms use the useForm hook with a generated TypeScript model; field-level constraints come from the same Jakarta Bean Validation annotations on the server record, surfaced through field(…​) bindings and a submit callback rather than Binder.

See also