Docs

Documentation versions (currently viewingVaadin 24)

Loading & Saving

This guide demonstrates how to load forms using Load from Selection and Fetch and Load, and save them using Single Save or Insert/Update patterns in Flow.

Tip
Part of a Series
This is part 3 of the Add a Form series. It builds on the concepts introduced in the Fields & Binding and Form Validation guides. You should also read the Overview.

Load from Selection

This approach is straightforward and requires no extra service call. However, this can result in “stale data,” where the grid displays values that appear saved but haven’t been persisted to the backend. Stale data can occur when the following conditions are met:

  • The FDOs are mutable,

  • Your form uses write-through mode, and

  • The user cancels or gets an error when trying to save.

The stale FDO remains in memory until you refresh the grid. To avoid this, you should do one of the following:

  • Use immutable FDOs.

  • Use buffered mode in your form. This ensures no data is written to the FDO until the user saves the form. However, it can still lead to stale data if the save operation itself fails.

  • Copy the FDO before passing it to the form. Copying ensures that the original object shown in the grid remains unchanged if the user cancels or validation fails.

  • Refresh the grid both after saving and canceling. This replaces any stale objects with fresh ones.

Here’s an example of the Fetch and Load approach in practice:

@Route("proposals")
public class ProposalView extends Main {
    private final Grid<Proposal> grid;
    private final ProposalForm form;

    public ProposalView(ProposalService proposalService) {
        // Configure the grid, create the form, etc.

        grid.addSelectionListener(event -> {
            event.getFirstSelectedItem().ifPresent(this::editProposal); 1
        });
    }

    private void editProposal(Proposal proposal) {
        form.setFormDataObject(new Proposal(proposal)); 2
        // Perform other UI updates, such as making the form visible,
        // updating the title, etc.
    }
}
  1. Calls editProposal() if an item has been selected in the grid.

  2. Uses a copy-constructor to create a copy of the proposal before binding it, avoiding the risk of stale data in the grid.

If you need a refresher on form binding, buffered mode and write-through mode, see the Fields & Binding guide.

Fetch and Load

This approach requires more code than the Load from Selection approach, but does not have the risk of stale data in the grid.

When using Fetch and Load, you first fetch the FDO from an application service, and then populate the form with it. You also have to handle the case when the application service returns an empty result. This could happen if the ID is invalid, or if the entity has been deleted by another session.

Here is an example of what Fetch and Load could look like:

@Route("proposals")
public class ProposalView extends Main {
    private final ProposalService proposalService;
    private final Grid<ProposalListEntry> grid; 1
    private final ProposalForm form;

    public ProposalView(ProposalService proposalService) {
        this.proposalService = proposalService;
        // Configure the grid, create the form, etc.

        grid.addSelectionListener(event -> {
            event.getFirstSelectedItem()
                .map(ProposalListEntry::proposalId) 2
                .ifPresent(this::editProposal);
        });
    }

    private void editProposal(long proposalId) {
        proposalService.findById(proposalId).ifPresentOrElse(
            this::editProposal, 3
            this::showNoProposalFound 4
        );
    }

    private void editProposal(Proposal proposal) {
        form.setFormDataObject(proposal); 5
        // Perform other UI updates, such as making the form visible,
        // updating the title, etc.
    }

    private void showNoProposalFound() {
        // Show an error message
    }
}
  1. The grid contains ProposalListEntry objects, not Proposal objects.

  2. Extracts the ID from the selected ProposalListEntry to fetch the corresponding Proposal.

  3. Populates the form if the proposal exists.

  4. Shows an error message if no proposal was found.

  5. The Proposal object is not used anywhere else so there’s no need to copy it.

Saving a Form

The process of saving a form in Vaadin typically follows this pattern:

  1. Validate the form.

  2. Write to the FDO.

  3. Call the application service to save the FDO.

  4. Re-initialize the form with the FDO returned by the service, refresh the grid, navigate to another view, or do something else.

How the application service is called depends on whether a single save operation or separate insert and update operations are used.

Single Save

Using a single save operation is a straightforward approach: get the FDO from the form and send it to the service for saving:

@Route("proposals")
public class ProposalView extends Main {
    private final ProposalService service;
    private final Grid<Proposal> grid;
    private final ProposalForm form;

    // (Constructor omitted for brevity.)

    private void editProposal(Proposal proposal) {
        form.setFormDataObject(new Proposal(proposal));
        // Perform other UI updates, such as making the form visible,
        // updating the title, etc.
    }

    private void saveProposal() {
        form.getFormDataObject().ifPresent(proposal -> { 1
            var savedProposal = service.save(proposal);
            grid.getDataProvider().refreshAll();
            editProposal(savedProposal);
        });
    }
}
  1. Validates the form and returns the FDO if successful.

Records and Single Save

When using records as FDO, Binder requires all record components to be bound to fields — including the ID. Because you don’t typically bind the ID to a UI component, you can create a dummy binding using ReadOnlyHasValue:

binder = new Binder<>(ProposalRecord.class);
binder.forField(new ReadOnlyHasValue<Long>(ignore -> {})).bind("proposalId");
binder.forField(titleField).bind("title");
binder.forField(proposalTypeField).bind("type");
// And so on...

Insert/Update

If you have separate workflows for creating and updating, having separate insert and update operations in your application service is easy: you call the corresponding method in the corresponding workflow. However, if you are using the same form and a single Save operation in the user interface, you have to keep track of which method to call.

If you are using a wrapper class for persistent items, you can do something like this:

private final ProposalService service;
private final ProposalForm form;
private @Nullable PersistentProposal existingProposal;
// ...

private void newProposal() {
    existingProposal = null;
    form.setFormDataObject(null);
}

private void editProposal(PersistentProposal existingProposal) {
    this.existingProposal = existingProposal;
    form.setFormDataObject(existingProposal.unwrap()); 1
}

private void saveProposal() {
    form.getFormDataObject().ifPresent(fdo => {
        if (existingProposal == null) {
            editProposal(service.insert(fdo));
        } else {
            editProposal(service.update(existingProposal));
        }
    });
}
  1. Assumes unwrap() returns a mutable FDO.

If you are using records, the principle is the same but the code for saving changes slightly:

private void saveProposal() {
    form.getFormDataObject().ifPresent(fdo -> {
        if (existingProposal == null) {
            editProposal(service.insert(fdo));
        } else {
            editProposal(service.update(existingProposal.withData(fdo))); 1
        }
    });
}
  1. Assumes there is a withData() method that returns a new wrapper record with the same ID as the original one, but with the specified wrapped FDO.