This is a second article in the series of building UI in Java with Vaadin. If you read the first part of this series, you already have a solid Vaadin-based frontend skeleton with
- Login form,
- A reusable main layout with navigation,
- A grid that can page through data efficiently using a CallbackDataProvider,
Filter fields so the UI refreshes immediately when search criteria changes.
In the second part, we’re taking that foundation and building on it. We’ll
- Add dialogs for displaying data in an overlay,
- Build forms with validation and binders so that only admins can change civilian data, and
- Introduce real-time updates so your UI can react instantly when data changes.
Table of Contents
Prerequisites and Project Setup
The demo project we are building is available on GitHub. A quick reminder: the application is called NeuroWatch, it displays civilians with implants and enables real-time monitoring of implant health data. The application uses Spring Security, Kafka, MongoDB, and Vaadin.
Add a Dialog Window
The grid currently displays only national IDs and civilians’ names. Let’s add a dialog window that will display detailed info about the civilian in an overlay.
First, we need to add a listener to the grid so that when the row is selected, a details dialog opens. Inside the configureGrid() method, add a listener to the grid via asSingleSelect() method, which puts the grid in single-row selection mode. The value change listener fires whenever the selection changes. If a civilian was selected, we call the openCivilianDialog(civilian) method.
private void configureGrid() {
grid.asSingleSelect().addValueChangeListener(e -> {
Optional.ofNullable(e.getValue()).ifPresent(this::openCivilianDialog);
});
}
Next, let’s write the openCivilianDialog() method. As we want the dialog to be built anew each time a civilian is selected, we create a Dialog object in this method. You can also do some styling, like setting a header and window width:
private void openCivilianDialog(Civilian civilian) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle(civilian.getLegalName());
dialog.setWidth("48rem");
}
Now, let’s create a tab called “Details” and add it to the Tabs. I know, using Tabs with just one tab might look strange, but it scales really well when we add “Edit”, “Add Implant”, etc.
Tab detailsTab = new Tab("Details");
Tabs tabs = new Tabs(detailsTab);
Then, we create the details panel component (we will write the logic in a separate method in just a few moments) and map a tab to a page via the Map<Tab, Component> map. The map ties each tab to its corresponding content component. With multiple tabs, it becomes a control center for switching tabs.
Component detailsPanel = buildDetailsPanel(civilian);
Map<Tab, Component> map = Map.of(
detailsTab, detailsPanel
);
After that, we need to place all pages into a single Div container. You can style it as well, for instance, set the container position. We then set up visibility by first hiding all tabs and then displaying the default one, which is the details panel.
Div pages = new Div(detailsPanel);
pages.getStyle().set("position", "relative");
map.values().forEach(p -> p.setVisible(false));
detailsPanel.setVisible(true);
Now, let’s adjust the tab switching logic. Add a SelectedChangeListener to the tabs so that whenever the user clicks a tab, we hide all pages and show only the page associated with the selected tab.
tabs.addSelectedChangeListener(e -> {
map.values().forEach(p -> p.setVisible(false));
map.get(tabs.getSelectedTab()).setVisible(true);
});
Finally, add tabs with pages to the dialog, and also add a “Close” button to the footer. With dialog.open() we open the dialog window.
dialog.add(tabs, pages);
dialog.getFooter().add(new Button("Close", ev -> dialog.close()));
dialog.open();
Great, the next step is to build the actual UI shown in the Details tab.
First, let’s create a FormLayout object, which is an optimal choice for label/value pairs. After that, we can add all required fields with the addFormItem() method. Note that we are using span because we have read-only values.
private Component buildDetailsPanel(Civilian civilian) {
/* CIVILIAN meta */
FormLayout civForm = new FormLayout();
civForm.addFormItem(new Span(civilian.getNationalId()), "National ID");
civForm.addFormItem(new Span(civilian.getBirthDate().toString()), "Birth date");
civForm.addFormItem(new Span(civilian.isCriminalRecord() ? "Yes" : "No"), "Criminal record");
civForm.addFormItem(new Span(civilian.isUnderSurveillance() ? "Yes" : "No"), "Under surveillance");
}
Civilians also have implants, the information about which we would also like to display. I suggest we use a nested grid for implant data for a smoother look.
We already know how to create and configure a grid from the previous article. What is worth mentioning here is that we populate the grid with implants already available as a collection in Civilian.
We can also constrain the grid height so that it becomes a small scrollable area instead of expanding the dialog to the max.
/* IMPLANT list */
Grid<Implant> implantGrid = new Grid<>(Implant.class, false);
implantGrid.addColumn(Implant::getType).setHeader("Type").setAutoWidth(true).setFlexGrow(1);
implantGrid.addColumn(Implant::getModel).setHeader("Model").setAutoWidth(true).setFlexGrow(1);
implantGrid.addColumn(Implant::getVersion).setHeader("Ver").setAutoWidth(true).setFlexGrow(1);
implantGrid.addColumn(Implant::getManufacturer).setHeader("Made by").setAutoWidth(true).setFlexGrow(1);
implantGrid.addColumn(Implant::getSerialNumber).setHeader("Serial #").setAutoWidth(true).setFlexGrow(1);
implantGrid.addColumn(Implant::getLotNumber).setHeader("Lot #").setAutoWidth(true).setFlexGrow(1);
implantGrid.addColumn(Implant::getInstalledAt).setHeader("Installed").setAutoWidth(true).setFlexGrow(1);
implantGrid.setItems(civilian.getImplants());
implantGrid.setHeight("200px"); // small scroll area
implantGrid.addThemeVariants(GridVariant.LUMO_WRAP_CELL_CONTENT);
Finally, let’s assemble everything in one vertical layout. Metadata form goes on top, the follows the “Implants” heading, and then the implant grid.
Padding/spacing disabled to keep the dialog compact and visually tight.
VerticalLayout content = new VerticalLayout(civForm, new H5("Implants"), implantGrid);
content.setPadding(false);
content.setSpacing(false);
content.setSizeFull();
return content;
As a result, we now have the following structure:
- The grid acts as a master list,
- Selecting an item opens a details dialog,
- The dialog uses scalable Tabs and page switching pattern,
- The details view combines a
FormLayoutfor metadata and aGridfor related items
This sets us up perfectly for the next step: adding extra tabs for editing.
Add an Edit Form with a Binder
Let's add another tab to our dialog window that lets users with the ADMIN role edit civilians.
First, let’s create a hasRole() method that accepts the required role and uses SecurityContextHolder to verify whether the user has this role.
private boolean hasRole(String role) {
return SecurityContextHolder.getContext().getAuthentication()
.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(role));
}
Note that this is a UI-level check. It hides or shows UI features, but we still need service/repository security enforcement, because UI checks don’t stop someone from calling the endpoints directly.
Now, add a new Tab editTab to the openCivilianDialog() method. Use the hasRole() method to check whether the user is admin. If yes, add the new tab to the tabs.
Create the editPanel Component, add it to the map together with the editTab, and to the Div. We will build this component in a separate method buildEditForm().
private void openCivilianDialog(Civilian civilian) {
Tab editTab = new Tab("Edit");
Tabs tabs = new Tabs(detailsTab);
if (hasRole("ROLE_ADMIN")) tabs.add(editTab);
Component editPanel = buildEditForm(civilian, dialog);
Map<Tab, Component> map = Map.of(
detailsTab, detailsPanel,
editTab, editPanel
);
Div pages = new Div(detailsPanel, editPanel);
Here, we create a TextField and two CheckBoxes initialized with the current values for the civilian data we can edit.
private Component buildEditForm(Civilian civilian, Dialog dialog) {
TextField name = new TextField("Legal name");
Checkbox criminal = new Checkbox("Criminal record", civilian.isCriminalRecord());
Checkbox surveil = new Checkbox("Under surveillance", civilian.isUnderSurveillance());
}
Then, we create a Vaadin Binder for the Civilian bean type. Binder is Vaadin’s way to connect UI fields to a Java object:
- The getter tells Binder how to read the current value from the object.
- The setter tells Binder how to write the edited value back.
BeanValidationBinder supports Java Bean Validation, so you don’t have to add the validation manually to the UI layer.
Using the bind method of the binder, we bind the text field and checkboxes to the relevant civilian values.
The readBean() method reads values from the civilian bean into the bound fields and sets the initial UI state.
Binder<Civilian> binder = new BeanValidationBinder<>(Civilian.class);
binder.bind(name, Civilian::getLegalName, Civilian::setLegalName);
binder.bind(criminal, Civilian::isCriminalRecord, Civilian::setCriminalRecord);
binder.bind(surveil, Civilian::isUnderSurveillance, Civilian::setUnderSurveillance);
binder.readBean(civilian);
Let’s also add the save Button with a ClickListener.
On save, we call:
- The
writeBeanIfValid(civilian)method of the binder that validates the bound fields. If valid, the values are written from the UI back into the civilian object; civilianService.updateCivilian(civilian)to persist changes;refresh()so the grid updates;close()to close the dialog.
Let’s also add the cancel Button to close the dialog window without persisting any changes.
Button save = new Button("Save", e -> {
if (binder.writeBeanIfValid(civilian)) {
civilianService.updateCivilian(civilian);
refresh();
dialog.close();
}
});
Button cancel = new Button("Cancel", e -> dialog.close());
And finally, we return a vertical stack containing the input fields and two buttons. The caller will add this to the dialog.
return new VerticalLayout(name, criminal, surveil, save, cancel);
Now, if you run the app and login as an admin, you will see an edit tab in the civilian dialog. You can change the values, and the updated data will be immediately displayed in the grid.
Enable Real-Time Updates
The demo application uses Kafka to receive and process a stream of implant monitoring logs. So, in this section, we will build a live-updating grid in Vaadin so the UI can update in real-time as the logs come in.
Create a LiveLogsView class. The only novelty in configuration is the implementation of AfterNavigationObserver. It lets the view run code after navigation completes, which is a good moment to start listening to live data.
@Route(value = "live-logs", layout = MainLayout.class)
@PermitAll
public class LiveLogsView extends VerticalLayout implements AfterNavigationObserver {
}
We need to add several instance fields:
LiveLogBusis a service class that serves as a source of live logs;LinkedList<ImplantMonitoringLog>will hold the logs currently displayed;ListDataProvider<ImplantMonitoringLog>is a Vaadin provider that serves items from an in-memory collection. We will wrap the list withCollections.synchronizedList(...)so basic operations are thread-safe, because logs arrive from another thread.Grid<ImplantMonitoringLog>will show the log rows.Checkboxwill control whether the grid jumps to the newest row automatically.Disposableis the reactor.core class that will hold the live stream subscription so we can dispose of it when leaving the view.
private final LiveLogBus bus;
private final LinkedList<ImplantMonitoringLog> buffer = new LinkedList<>();
private final ListDataProvider<ImplantMonitoringLog> data =
new ListDataProvider<>(Collections.synchronizedList(buffer));
private final Grid<ImplantMonitoringLog> grid = new Grid<>(ImplantMonitoringLog.class, false);
private final Checkbox autoScroll = new Checkbox("Auto-scroll", true);
private Disposable subscription;
Then, we need to build the UI: configure the toolbar, the grid. Nothing new here, so I’ll skip the explanation. For instructions on configuring the grid, refer to the previous article.
public LiveLogsView(LiveLogBus bus) {
this.bus = bus;
setSizeFull();
configureGrid();
add(buildToolbar(), grid);
expand(grid);
}
private Component buildToolbar() {
HorizontalLayout bar = new HorizontalLayout(autoScroll);
bar.setAlignItems(Alignment.CENTER);
bar.setWidthFull();
bar.setJustifyContentMode(JustifyContentMode.START);
return bar;
}
private void configureGrid() {
grid.setDataProvider(data);
grid.addColumn(ImplantMonitoringLog::getTimestamp).setHeader("Time").setAutoWidth(true);
grid.addColumn(ImplantMonitoringLog::getImplantSerialNumber).setHeader("Serial").setAutoWidth(true);
grid.addColumn(ImplantMonitoringLog::getCivilianNationalId).setHeader("National ID").setAutoWidth(true);
grid.addColumn(l -> String.format("%.1f µW", l.getPowerUsageUw())).setHeader("Power").setAutoWidth(true);
grid.addColumn(l -> String.format("%.1f %%", l.getCpuUsagePct())).setHeader("CPU").setAutoWidth(true);
grid.addColumn(l -> String.format("%.2f ms", l.getNeuralLatencyMs())).setHeader("Latency").setAutoWidth(true);
grid.addColumn(l -> l.getLocation() != null ? (l.getLocation().getY() + ", " + l.getLocation().getX()) : "")
.setHeader("Lat, Lon").setAutoWidth(true);
grid.addThemeVariants(GridVariant.LUMO_ROW_STRIPES, GridVariant.LUMO_WRAP_CELL_CONTENT);
grid.setHeightFull();
}
The most interesting things happen in the afterNavigation() method that we must override.
First and foremost, we must subscribe to the stream of events emitted by LiveLogBus when the view becomes active with bus.stream().subscribe(...).
Within the subscribe() method, update the UI safely for each new log:
getUI().ifPresent(...)ensures the view is attached to a UI.ui.access(...)runs code inside Vaadin’s UI lock from a background thread. This bit is super important because without ui.access, updating Vaadin components from a non-UI thread can lead to unpredictable behavior.
@Override
public void afterNavigation(AfterNavigationEvent afterNavigationEvent) {
// subscribe when the view becomes active
subscription = bus.stream().subscribe(log ->
getUI().ifPresent(ui -> ui.access(() -> {
...
}))
);
}
Then, we
- Add the log to the front of the list with
addFirst()so the newest one is at the top; - Trim the list to max 5000 rows to avoid unbounded memory growth;
- Update the grid with
data.refreshAll(); - If auto-scroll is on, scroll to row 0, which is the newest one.
@Override
public void afterNavigation(AfterNavigationEvent afterNavigationEvent) {
subscription = bus.stream().subscribe(log ->
getUI().ifPresent(ui -> ui.access(() -> {
buffer.addFirst(log);
// trim to avoid unbounded growth (keep last 5000 rows)
if (buffer.size() > 5000) buffer.removeLast();
data.refreshAll();
if (autoScroll.getValue() && !buffer.isEmpty()) {
grid.scrollToIndex(0);
}
}))
);
}
When the user navigates away or closes the tab, the view should be detached. We can define this logic in the onDetach() method that we also override. Here, we dispose of the subscription so the view stops consuming events, which prevent memory leaks or subscribers piling up.
@Override
public void onDetach(DetachEvent detachEvent) {
if (subscription != null) {
subscription.dispose();
subscription = null;
}
}
Finally, one more extremely important thing is to enable Vaadin server push with the @Push annotation added to a class extending the AppShellConfigurator. This functionality allows ui.access(...) updates to be sent to the browser automatically.
Without push, the server could update the component tree, but the browser wouldn’t see it until the next client request such as a click.
@SpringBootApplication
@Push(PushMode.AUTOMATIC)
public class NeuroWatchApplication implements AppShellConfigurator {
public static void main(String[] args) {
SpringApplication.run(NeuroWatchApplication.class, args);
}
}
Conclusion
We have built a beautiful UI with dialogs, edit forms, and a live monitoring console. Vaadin makes this kind of thing feel straightforward. You stay in Java, you keep your UI and backend logic in the same mental model, and you still get modern UX patterns like dialogs, tabs, and real-time updates with clean, predictable code.
If you want more tutorials like this, subscribe to our newsletter so as not to miss them!






