CRUD Form Editor using Signals
This guide demonstrates how to create a reactive CRUD (Create, Read, Update, Delete) form editor that synchronizes with a grid using signals. The approach eliminates complex event handling chains while maintaining robust functionality.
The Use Case
We want to create an interface where users can:
-
View items in a grid
-
Select an item for editing
-
See immediate form updates based on selection
-
Create new or update existing items
-
Save changes with proper validation feedback
The key challenge is managing the selected item state reactively, ensuring UI components stay perfectly synchronized without manual coordination.
Architecture Overview
Our solution leverages three core concepts with clean separation of concerns:
-
Signals for reactive state management (especially for the selected item)
-
Binder for form-to-data binding and validation
-
Effects to synchronize data between grid selection and form
Implementation Steps
1. Create the Item Grid and Form
First, we need a data model class that represents our items:
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
Set up a grid to display items and manage selection:
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
Create and bind the form fields using the binder:
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
2. Synchronize Grid Selection and the Form using a Signal
Use ValueSignal to track the currently selected item:
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
This signal will hold either a real selected item (for editing) or a special NEW_ITEM placeholder for creating new items.
The new item is the initial value, as nothing is selected by default.
Now create bidirectional synchronization between grid selection and the signal:
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
Next, add an effect to update the binder with the selected item data:
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
|
Note
|
This example uses Using |
Now create and bind individual form fields:
3. Create a Dynamic Save Button
Add a derived boolean signal to distinguish between creating a new and exiting an existing item selected in the grid.
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
Add the save button with reactive label and behavior depending on whether a new or existing item is being edited:
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
Combine all components and add them to your view:
Source code
CrudEditorExample.java
package com.vaadin.demo.flow.signals.usecase;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
import org.jspecify.annotations.NonNull;
import java.util.List;
@Route("crud-editor-with-signals")
public class CrudEditorExample extends VerticalLayout {
static private final Item NEW_ITEM = new Item();
public CrudEditorExample() {
Grid<Item> itemGrid = new Grid<>(Item.class);
itemGrid.setColumns("product", "category");
itemGrid.setItems(listItems());
ValueSignal<Item> selectedItemSignal = new ValueSignal<>(NEW_ITEM);
// Update signal when user selects an item in the grid
itemGrid.addSelectionListener((event) -> selectedItemSignal
.set(event.getFirstSelectedItem().orElse(NEW_ITEM)));
// Keep grid selection in sync with the signal (if signal changes
// programmatically)
Signal.effect(this, () -> itemGrid.select(selectedItemSignal.get()));
add(itemGrid);
Binder<Item> itemBinder = new Binder<>();
TextField productNameField = new TextField("Product");
itemBinder.forField(productNameField).bind(Item::getProduct,
Item::setProduct);
ComboBox<String> categoryField = new ComboBox<>("Category");
categoryField.setItems(List.of("Office", "Tech", "Stationery"));
itemBinder.forField(categoryField).bind(Item::getCategory,
Item::setCategory);
Signal.effect(this,
() -> itemBinder.readBean(selectedItemSignal.get()));
// Derived signal, true when no item is selected (creating a new item)
final Signal<Boolean> creatingItemSignal = selectedItemSignal
.map(NEW_ITEM::equals);
Button saveButton = new Button(
() -> creatingItemSignal.get() ? "Create" : "Update");
saveButton.addClickListener((event) -> {
try {
// Note: the method uses `peek()` to read signals, as the click
// listener should not subscribe to the state changes.
boolean creatingItem = creatingItemSignal.peek();
// Create a new item instance or reuse existing for editing
Item item = creatingItem ? new Item()
: selectedItemSignal.peek();
// Save changes from the binder
itemBinder.writeBean(item);
item = saveItem(item);
// Update the selected item signal to use the saved item
selectedItemSignal.set(item);
// Refresh the data in the grid
itemGrid.getDataProvider().refreshAll();
// Show success notification
final String successMessage = creatingItem ? "Item added"
: "Item updated";
Notification
.show(successMessage, 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (ValidationException e) {
// Show validation error
Notification.show("Invalid item", 3000,
Notification.Position.BOTTOM_END);
}
});
FormLayout formLayout = new FormLayout();
formLayout.setAutoResponsive(true);
formLayout.addFormRow(productNameField, categoryField, saveButton);
add(formLayout);
}
static private List<Item> listItems() {
return List.of(new Item(1, "Laptop", "Tech"),
new Item(2, "Desk Chair", "Office"),
new Item(3, "Monitor", "Tech"), new Item(4, "Keyboard", "Tech"),
new Item(5, "Mouse Pad", "Office"),
new Item(6, "Printer Paper", "Office"),
new Item(7, "Stapler", "Office"),
new Item(8, "Desk", "Stationary"),
new Item(9, "Notebook", "Office"),
new Item(10, "Pen Set", "Office"),
new Item(11, "Cable Ties", "Tech"),
new Item(12, "Extension Cord", "Tech"));
}
static private @NonNull Item saveItem(@NonNull Item item) {
// Make the item change persistent, for example, using a database
final Item savedItem = new Item();
savedItem.setId(item.getId());
savedItem.setProduct(item.getProduct());
savedItem.setCategory(item.getCategory());
return savedItem;
}
/**
* Item bean class for storing supply information
*/
static public class Item {
private long id;
@NonNull
private String product = "";
@NonNull
private String category = "";
// Constructor
public Item() {
}
// Parameterized constructor with all fields
public Item(long id, @NonNull String product,
@NonNull String category) {
this.id = id;
this.product = product;
this.category = category;
}
// Getters and Setters
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public @NonNull String getProduct() {
return product;
}
public void setProduct(@NonNull String product) {
this.product = product;
}
public @NonNull String getCategory() {
return category;
}
public void setCategory(@NonNull String category) {
this.category = category;
}
@Override
public String toString() {
return String.format("Item[id=%d, product='%s', category='%s']", id,
product, category);
}
}
}
Key Takeaways
- Using signals for UI state
-
The
selectedItemSignalbecomes the single source of truth for "which item is being edited". All components that need to display/edit this item can react to changes in this signal. The grid and form stay perfectly synchronized without manual coordination. - Adding derived signals for computed logic
-
The
creatingItemSignaltells us whether we’re editing a new (unsaved) item or an existing one. This drives dynamic behavior like button labels and data persistence logic. - Using effects for UI reactivity
-
Signal.effect()performs reactive UI updates based on signal dependencies. Binder::readBean(bean)andBiner::writeBean(bean)-
Used to synchronize the form state between the binder and the bean state signal.
- When to use
Signal::peek() -
When you need the current value but don’t want to create a dependency on the signal. For example, in event handlers where you just need the current state without subscribing to changes.
Related Topics
-
Form Binding with Dynamic Validation - Combining Binder with signals for forms with dynamic validation logic
-
Local Signals - Understanding ValueSignal and two-way binding
-
Effects and Computed Signals - Creating derived values
-
Component Bindings - Binding signals to component properties
-
Grid - Reference documentation for Grid component