Forms, CRUD and Master-Detail

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.

A working editor combines three parts: a bound form, a list to pick records from, and Save / Cancel / Delete wiring. This page assembles them, following Forms & Data. The Binder API each form depends on is covered in depth in Data Binding and is not repeated here.

The form view

A form view is a FormLayout holding the fields, one Binder shared by all of them, and Save / Cancel buttons. Keep it buffered (enter with readBean, commit with writeBeanIfValid) so Cancel is just a re-read — see Fields & Binding.

public class CustomerForm extends FormLayout {

    private final TextField firstName = new TextField("First name");
    private final TextField lastName = new TextField("Last name");
    private final EmailField email = new EmailField("Email");

    private final Span status = new Span();
    private final Button save = new Button("Save");
    private final Button cancel = new Button("Cancel");

    private final Binder<Customer> binder = new BeanValidationBinder<>(Customer.class);
    private Customer customer;

    public CustomerForm(Runnable onSave, Runnable onCancel) {
        binder.bindInstanceFields(this);
        binder.setStatusLabel(status);                     // whole-form messages land here

        save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        save.addClickShortcut(Key.ENTER);
        save.addClickListener(e -> {
            if (binder.writeBeanIfValid(customer)) {
                onSave.run();
            }
        });
        cancel.addClickListener(e -> {
            binder.readBean(customer);                     // discard the working copy
            onCancel.run();
        });

        add(firstName, lastName, email, status, new HorizontalLayout(save, cancel));
    }

    public void edit(Customer customer) {
        this.customer = customer;
        binder.readBean(customer);                         // null clears the form
        setVisible(customer != null);
    }

    Customer getCustomer() {
        return customer;
    }
}

Validation feedback

binder.validate() returns a BinderValidationStatus<T> — the whole-form result. hasErrors() and getValidationErrors() cover field-level and bean-level failures together; getFieldValidationStatuses() gives the per-field BindingValidationStatus list. Route one binding’s message to a component beside its field with withStatusLabel, and take over the form-wide display with setValidationStatusHandler. See Form Validation.

Span emailError = new Span();

binder.forField(email)
        .withValidator(new EmailValidator("Not a valid address"))
        .withStatusLabel(emailError)                       // this binding's message only
        .bind(Customer::getEmail, Customer::setEmail);

// on demand -- e.g. before a bulk action
BinderValidationStatus<Customer> result = binder.validate();
if (result.hasErrors()) {
    String summary = result.getValidationErrors().stream()
            .map(ValidationResult::getErrorMessage)
            .collect(Collectors.joining("; "));
    status.setText(summary);
}

BeanValidationBinder already turns every Jakarta Bean Validation annotation on Customer into a binding validator, so most rules need no withValidator call — see Data Binding.

Dirty state and navigate-away

binder.hasChanges() reports whether the fields differ from what was last read (buffered mode). addStatusChangeListener fires on every edit and every validation run — the place to enable or disable Save:

binder.addStatusChangeListener(e ->
        save.setEnabled(binder.hasChanges() && !e.hasValidationErrors()));

To warn on navigation away from unsaved edits, have the view implement BeforeLeaveObserver, postpone() the navigation, and resume it from a confirmation dialog. Routing and Navigation covers the navigation lifecycle.

@Override
public void beforeLeave(BeforeLeaveEvent event) {
    if (!binder.hasChanges()) {
        return;
    }
    BeforeLeaveEvent.ContinueNavigationAction action = event.postpone();
    ConfirmDialog dialog = new ConfirmDialog();
    dialog.setHeader("Discard unsaved changes?");
    dialog.setText("This form has edits that have not been saved.");
    dialog.setCancelable(true);
    dialog.setConfirmText("Discard");
    dialog.addConfirmListener(e -> action.proceed());
    dialog.open();
}

The master-detail view

Put a Grid (the master) next to the form (the detail), wire grid selection to form.edit(selected) — which calls binder.readBean — and refresh the grid’s DataProvider after every write. The Grid Component covers the Grid and its data providers.

MasterDetailLayout is the newer component: it shows master and detail side by side on wide screens and collapses the detail into an overlay on narrow ones. SplitLayout is the older primitive — a draggable splitter with no responsive behaviour.

@Route("customers")
public class CustomersView extends Div {

    private final Grid<Customer> grid = new Grid<>(Customer.class, false);
    private final CustomerForm form;
    private final CustomerService service;

    public CustomersView(CustomerService service) {
        this.service = service;

        grid.addColumn(Customer::getFirstName).setHeader("First name");
        grid.addColumn(Customer::getLastName).setHeader("Last name");
        grid.addColumn(Customer::getEmail).setHeader("Email");
        grid.setItems(q -> service.list(q.getOffset(), q.getLimit()).stream());

        form = new CustomerForm(this::saveAndRefresh, grid::deselectAll);
        grid.asSingleSelect().addValueChangeListener(e -> form.edit(e.getValue()));

        Button add = new Button("New customer", e -> {
            grid.deselectAll();
            form.edit(new Customer());
        });

        MasterDetailLayout layout = new MasterDetailLayout();
        layout.setMaster(new VerticalLayout(add, grid));
        layout.setDetail(form);
        layout.setSizeFull();
        add(layout);
    }

    private void saveAndRefresh() {
        service.save(form.getCustomer());
        grid.getDataProvider().refreshAll();              // re-fetch after the write
        grid.deselectAll();
    }

    private void delete(Customer customer) {
        service.delete(customer);
        grid.getDataProvider().refreshAll();
        form.edit(null);
    }
}

Swapping in the older splitter is a one-line change:

SplitLayout split = new SplitLayout(new VerticalLayout(add, grid), form);
split.setSplitterPosition(60);                            // master gets 60%
split.setSizeFull();

The Crud component

Crud<E> (com.vaadin.flow.component.crud.Crud) packages the whole master-detail pattern — grid, editor, New / Edit / Delete controls — into one component. It ships in the vaadin-crud-flow module and needs a commercial (Pro) subscription.

Crud<Customer> crud = new Crud<>(Customer.class, createEditor());
crud.setDataProvider(new CustomerCrudDataProvider(service));

crud.addNewListener(e -> e.getItem().setRegistered(LocalDate.now()));
crud.addSaveListener(e -> service.save(e.getItem()));    // fired for new and edited items
crud.addDeleteListener(e -> service.delete(e.getItem()));
crud.addCancelListener(e -> {});                          // editor dismissed, no change

CrudEditor<Customer> createEditor() {
    TextField firstName = new TextField("First name");
    EmailField email = new EmailField("Email");
    FormLayout form = new FormLayout(firstName, email);

    Binder<Customer> binder = new BeanValidationBinder<>(Customer.class);
    binder.bind(firstName, "firstName");
    binder.bind(email, "email");
    return new BinderCrudEditor<>(binder, form);          // Binder-backed CrudEditor
}
  • CrudEditor<E> / BinderCrudEditor<E> — the detail form; BinderCrudEditor drives it from a Binder.

  • CrudGrid<E> — the default master grid; construct one explicitly (new CrudGrid<>(Customer.class, true)) and pass it to a Crud constructor to control the columns.

  • CrudDataProvider<E> — a DataProvider keyed on CrudFilter; back it with your service, or use crud.setItems(collection) for a fixed in-memory list.

  • Events: Crud.NewEvent, Crud.EditEvent, Crud.SaveEvent, Crud.DeleteEvent, Crud.CancelEvent, reached through the add*Listener methods above.

Confirming deletes with ConfirmDialog

ConfirmDialog is a small modal with Confirm / Cancel / Reject actions. It is also a Pro component (vaadin-confirm-dialog). Use it to guard a destructive action.

void confirmDelete(Customer customer) {
    ConfirmDialog dialog = new ConfirmDialog();
    dialog.setHeader("Delete " + customer.getFirstName() + "?");
    dialog.setText("This cannot be undone.");
    dialog.setCancelable(true);                           // show the Cancel button
    dialog.setConfirmText("Delete");
    dialog.setConfirmButtonTheme("error primary");
    dialog.addConfirmListener(e -> delete(customer));
    dialog.open();
}

Crud already shows its own delete confirmation; wire ConfirmDialog into the hand-rolled master-detail view’s Delete button instead.

Editing in a dialog or drawer

Instead of an always-present detail pane, the editor can live in a Dialog or a slide-in drawer. The Vaadin convention from Dialogs & Drawers: dialogs create new items, drawers edit the selected one, and closing the drawer clears the grid selection.

Dialog dialog = new Dialog();
dialog.setHeaderTitle("New customer");
dialog.add(form);
dialog.getFooter().add(cancel, save);
dialog.open();

A drawer is a full-height panel docked to one edge — often a styled Div toggled with a CSS class, or the detail area of a MasterDetailLayout with setForceOverlay(true) so it always overlays rather than splitting.

Optimistic locking on concurrent edits

When two users load the same record and both save, the second write must not silently overwrite the first. Add a JPA @Version field: the persistence provider increments it on every update and rejects a write whose version is stale. See Optimistic Locking and Data Persistence.

@Entity
public class Customer {

    @Id
    @GeneratedValue
    private Long id;

    @Version
    private long version;                                 // provider-managed, do not set by hand

    // other fields, getters and setters
}

On save, catch the conflict and tell the user to reload — there is no safe automatic merge. Plain JPA throws jakarta.persistence.OptimisticLockException (often wrapped in a RollbackException); Spring Data JPA translates it to org.springframework.orm.ObjectOptimisticLockingFailureException.

save.addClickListener(e -> {
    if (!binder.writeBeanIfValid(customer)) {
        return;
    }
    try {
        service.save(customer);                           // flush compares @Version
        grid.getDataProvider().refreshAll();
    } catch (ObjectOptimisticLockingFailureException ex) {
        Notification.show(
                "Someone else changed this record while you were editing. "
                        + "Reload and reapply your changes.",
                5000, Notification.Position.MIDDLE);
        form.edit(service.reload(customer.getId()));      // pull the current version
    }
});

In Hilla / React: Crud and MasterDetailLayout have React counterparts in @vaadin/react-components, and ConfirmDialog is available as <ConfirmDialog>. Dirty state comes from useForm().dirty; a navigate-away guard uses a React Router blocker rather than BeforeLeaveObserver. An optimistic-lock conflict surfaces as an EndpointError rejected from the generated endpoint client, handled in the submit callback.

See also