Vaadin #
Building a web interface for a Java enterprise application usually means separating two worlds: the backend team writes Java, the frontend team writes JavaScript, and the two must coordinate through a REST API. Vaadin offers a completely different approach — you build the entire UI in pure Java, without touching HTML, CSS, or JavaScript directly. Every UI component is a Java object, event handlers are Java methods, and data binding is done type-safely at compile time. Behind the scenes, Vaadin handles the communication between server and browser over WebSocket, efficiently renders UI changes, and produces accessible HTML. This approach makes Vaadin a very productive choice for Java teams that need to build internal applications, admin dashboards, or complex CRUD systems without having to master the modern frontend ecosystem.
Vaadin Architecture #
Understanding how Vaadin works behind the scenes is important for writing performant applications and avoiding common pitfalls.
Server-Side Rendering and State #
Vaadin keeps the UI state on the server. Each browser session has one UI instance running on the server. When a user clicks a button, the event is sent to the server over WebSocket, the server processes the event and updates the UI state, then the changes are sent back to the browser as a differential update — only the parts that changed are sent, not the whole page.
sequenceDiagram
participant Browser
participant Server as Vaadin Server\n(Java)
Browser->>Server: HTTP request — access /dashboard
Server-->>Browser: Initial HTML + Vaadin JS bundle
Browser->>Server: WebSocket: click "Save" button
Server->>Server: Run Java event handler\nupdate UI state
Server-->>Browser: WebSocket: differential update\n(only the changed parts)
Browser->>Browser: Update the DOMComponents and the Server-Side Virtual DOM #
Vaadin has a virtual DOM on the server side. When you call button.setText("Saved"), Vaadin doesn’t immediately send an update to the browser — it records the change, collects all changes within one request-response cycle, then sends an efficient diff.
flowchart TD
subgraph Server[Java Server]
UI["UI Instance\nper session"]
VDOM["Virtual DOM\nServer-side"]
COMP["Java Components\nButton TextField Grid"]
UI --> VDOM
COMP --> VDOM
end
subgraph Browser[Browser]
DOM[Real DOM]
VAADINJS["Vaadin JS\nClient-side"]
DOM --> VAADINJS
end
VDOM -->|WebSocket\ndifferential update| VAADINJS
VAADINJS -->|events: click, input| VDOMBecause the state lives on the server, every open browser tab is a separate session with its own UI instance. This means you don’t need to manage state manually on the client side — but it also means every active user uses memory on the server.
Project Setup #
The easiest way to create a new Vaadin project is through start.vaadin.com or Spring Initializr with the Vaadin dependency.
<!-- pom.xml -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-bom</artifactId>
<version>24.4.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Vaadin + Spring Boot integration -->
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<!-- Spring Data JPA for database access -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>24.4.0</version>
<executions>
<execution>
<goals>
<goal>prepare-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
The Vaadin project directory structure:
src/
├── main/
│ ├── java/
│ │ └── com/example/app/
│ │ ├── Application.java
│ │ ├── views/ ← Views (UI pages)
│ │ │ ├── MainView.java
│ │ │ └── products/
│ │ │ └── ProductView.java
│ │ ├── service/ ← business logic
│ │ └── data/ ← entities and repositories
│ └── resources/
│ ├── application.properties
│ └── META-INF/
│ └── resources/
│ └── frontend/ ← custom CSS (optional)
Views and Routing #
In Vaadin, each “page” is a View — a Java class annotated with @Route. The Vaadin Router handles navigation between views without full page reloads.
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.PageTitle;
// @Route("") → the main page (URL: /)
// @Route("products") → URL: /products
// @Route("admin/users") → URL: /admin/users
@Route("")
@PageTitle("Home | Online Store")
public class HomeView extends VerticalLayout {
public HomeView() {
// All components are added in the constructor
H1 title = new H1("Welcome to the Online Store");
Paragraph description = new Paragraph(
"Manage products, orders, and customers from one place."
);
// add() adds components to this layout
add(title, description);
// Styling via the Java API
setSizeFull();
setAlignItems(Alignment.CENTER);
setJustifyContentMode(JustifyContentMode.CENTER);
}
}
Main Layout #
Views sharing common chrome (navbar, sidebar) can use a shared layout:
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.Layout;
// @Layout makes this class the layout for all views in the same package
@Layout
public class MainLayout extends AppLayout {
public MainLayout() {
// Top navbar
DrawerToggle toggle = new DrawerToggle();
H2 appName = new H2("Online Store");
appName.getStyle().set("font-size", "var(--lumo-font-size-l)")
.set("margin", "0");
addToNavbar(toggle, appName);
// Sidebar navigation
SideNav nav = new SideNav();
nav.addItem(new SideNavItem("Home", HomeView.class,
new com.vaadin.flow.component.icon.VaadinIcon.HOME.create()));
nav.addItem(new SideNavItem("Products", ProductView.class,
new com.vaadin.flow.component.icon.VaadinIcon.PACKAGE.create()));
nav.addItem(new SideNavItem("Orders", OrderView.class,
new com.vaadin.flow.component.icon.VaadinIcon.CART.create()));
addToDrawer(nav);
}
}
// A view with a layout — just specify the layout in @Route
@Route(value = "products", layout = MainLayout.class)
@PageTitle("Products | Online Store")
public class ProductView extends VerticalLayout {
// view content...
}
Programmatic Navigation #
import com.vaadin.flow.component.UI;
import com.vaadin.flow.router.RouteParameters;
// Navigate to a specific route
UI.getCurrent().navigate(ProductView.class);
// Navigate with URL parameters
UI.getCurrent().navigate(ProductDetailView.class, new RouteParameters("id", "42"));
// Navigate with a URL string (for complex cases)
UI.getCurrent().navigate("products/42/edit");
Basic UI Components #
Vaadin provides a rich component library. Here are the most commonly used components.
Inputs and Simple Forms #
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
public class BasicComponentsDemo extends VerticalLayout {
public BasicComponentsDemo() {
// TextField — text input
TextField nameTF = new TextField("Product Name");
nameTF.setPlaceholder("Enter the product name...");
nameTF.setRequired(true);
nameTF.setMinLength(3);
nameTF.setMaxLength(100);
nameTF.setWidth("300px");
// PasswordField
PasswordField passwordTF = new PasswordField("Password");
// NumberField — numeric input
NumberField priceNF = new NumberField("Price");
priceNF.setPrefixComponent(new com.vaadin.flow.component.html.Span("Rp"));
priceNF.setMin(0);
priceNF.setStep(1000);
// ComboBox — dropdown with search
ComboBox<String> categoryCB = new ComboBox<>("Category");
categoryCB.setItems("Electronics", "Clothing", "Food", "Sports");
categoryCB.setPlaceholder("Choose a category");
// DatePicker
DatePicker expiryDP = new DatePicker("Expiry Date");
expiryDP.setMin(java.time.LocalDate.now());
// Checkbox
Checkbox activeCB = new Checkbox("Product active");
activeCB.setValue(true);
// Button with variant styling
Button saveBtn = new Button("Save");
saveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancelBtn = new Button("Cancel");
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
// Event handler — button click
saveBtn.addClickListener(event -> {
String name = nameTF.getValue();
if (name.isBlank()) {
// Show an inline error on the field
nameTF.setErrorMessage("Name must not be empty");
nameTF.setInvalid(true);
return;
}
// Show a notification
Notification notif = Notification.show("Product '" + name + "' saved successfully!");
notif.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
notif.setPosition(Notification.Position.BOTTOM_END);
});
HorizontalLayout buttonLayout = new HorizontalLayout(saveBtn, cancelBtn);
add(nameTF, priceNF, categoryCB, expiryDP, activeCB, buttonLayout);
setSpacing(true);
setPadding(true);
}
}
Dialogs and Confirmation #
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
public class DialogDemo {
// Custom dialog
public static Dialog createFormDialog(String title) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle(title);
dialog.setWidth("500px");
dialog.setCloseOnEsc(true);
dialog.setCloseOnOutsideClick(false);
// Dialog content
VerticalLayout content = new VerticalLayout();
TextField nameTF = new TextField("Name");
content.add(nameTF);
dialog.add(content);
// Footer with buttons
Button save = new Button("Save", e -> {
// save processing
dialog.close();
});
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button close = new Button("Close", e -> dialog.close());
dialog.getFooter().add(close, save);
return dialog;
}
// ConfirmDialog — delete confirmation dialog
public static void confirmDelete(String productName, Runnable onConfirm) {
ConfirmDialog dialog = new ConfirmDialog();
dialog.setHeader("Delete Product?");
dialog.setText("Are you sure you want to delete '" + productName + "'? " +
"This action cannot be undone.");
dialog.setCancelable(true);
dialog.setCancelText("Cancel");
dialog.setConfirmText("Delete");
dialog.setConfirmButtonTheme("error primary");
dialog.addConfirmListener(event -> onConfirm.run());
dialog.open();
}
}
Grids — Data Tables #
Grid is the most important Vaadin component for displaying tabular data. It supports lazy loading, sorting, filtering, and selection.
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.grid.GridVariant;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.data.renderer.NumberRenderer;
import java.text.NumberFormat;
import java.util.Locale;
public class ProductGrid extends VerticalLayout {
private final Grid<Product> grid;
private final ProductService productService;
public ProductGrid(ProductService productService) {
this.productService = productService;
grid = new Grid<>(Product.class, false); // false = don't auto-generate columns
configureGrid();
loadData();
add(grid);
setSizeFull();
}
private void configureGrid() {
grid.setSizeFull();
grid.addThemeVariants(GridVariant.LUMO_ROW_STRIPES, GridVariant.LUMO_BORDERED);
// Explicit column definitions
grid.addColumn(Product::getName)
.setHeader("Product Name")
.setSortable(true)
.setFlexGrow(2) // this column gets 2x more space
.setResizable(true);
grid.addColumn(new NumberRenderer<>(
Product::getPrice,
NumberFormat.getCurrencyInstance(new Locale("id", "ID"))
))
.setHeader("Price")
.setSortable(true)
.setTextAlign(com.vaadin.flow.component.grid.ColumnTextAlign.END)
.setFlexGrow(1);
grid.addColumn(Product::getStock)
.setHeader("Stock")
.setSortable(true)
.setFlexGrow(0)
.setWidth("100px");
// A column with a custom component — stock status badge
grid.addColumn(new ComponentRenderer<>(product -> {
com.vaadin.flow.component.html.Span badge = new com.vaadin.flow.component.html.Span(
product.getStock() > 0 ? "In Stock" : "Out of Stock"
);
badge.getElement().getThemeList().add(
"badge " + (product.getStock() > 0 ? "success" : "error")
);
return badge;
}))
.setHeader("Status")
.setFlexGrow(0)
.setWidth("120px");
// Actions column — edit and delete buttons
grid.addColumn(new ComponentRenderer<>(product -> {
Button editBtn = new Button("Edit",
new com.vaadin.flow.component.icon.Icon("lumo", "edit"));
editBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
editBtn.addClickListener(e -> openEditForm(product));
Button deleteBtn = new Button("Delete",
new com.vaadin.flow.component.icon.Icon("lumo", "cross"));
deleteBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY,
ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
deleteBtn.addClickListener(e ->
DialogDemo.confirmDelete(product.getName(), () -> deleteProduct(product))
);
return new HorizontalLayout(editBtn, deleteBtn);
}))
.setHeader("Actions")
.setFlexGrow(0)
.setWidth("180px");
// Selection mode — single selection
grid.setSelectionMode(Grid.SelectionMode.SINGLE);
grid.addSelectionListener(event ->
event.getFirstSelectedItem().ifPresent(this::showDetails)
);
}
private void loadData() {
grid.setItems(productService.getAllProducts());
}
// For large datasets — use lazy loading
private void loadDataLazy() {
grid.setItems(query -> {
// Only load the visible data — efficient for thousands of rows
int offset = query.getOffset();
int limit = query.getLimit();
return productService.findAll(offset, limit).stream();
});
}
private void openEditForm(Product product) {
// open a dialog or navigate to the edit page
}
private void deleteProduct(Product product) {
productService.delete(product.getId());
loadData(); // refresh the grid
Notification.show("Product deleted successfully").addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
private void showDetails(Product product) {
// show a detail panel next to the grid
}
}
Data Binding with Binder #
Binder is Vaadin’s mechanism for connecting UI fields to Java model objects bidirectionally, with validation included.
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.ValidationException;
import com.vaadin.flow.data.validator.StringLengthValidator;
import com.vaadin.flow.data.validator.DoubleRangeValidator;
import com.vaadin.flow.data.converter.StringToDoubleConverter;
public class ProductForm extends VerticalLayout {
private final Binder<Product> binder = new Binder<>(Product.class);
private Product currentProduct;
private final TextField nameTF = new TextField("Product Name");
private final NumberField priceNF = new NumberField("Price");
private final NumberField stockNF = new NumberField("Stock");
private final ComboBox<String> categoryCB = new ComboBox<>("Category");
private final Checkbox activeCB = new Checkbox("Active");
private final Button saveBtn = new Button("Save");
private final Button cancelBtn = new Button("Cancel");
private final ProductService productService;
public ProductForm(ProductService productService) {
this.productService = productService;
configureFields();
configureBinder();
configureButtons();
add(nameTF, priceNF, stockNF, categoryCB, activeCB,
new HorizontalLayout(saveBtn, cancelBtn));
setWidth("400px");
setPadding(true);
setSpacing(true);
}
private void configureFields() {
categoryCB.setItems("Electronics", "Clothing", "Food", "Sports");
priceNF.setPrefixComponent(new com.vaadin.flow.component.html.Span("Rp"));
priceNF.setMin(0);
stockNF.setMin(0);
stockNF.setStep(1);
}
private void configureBinder() {
// Bind fields to model properties with validation
binder.forField(nameTF)
.withValidator(new StringLengthValidator(
"Name must be 3-100 characters", 3, 100))
.bind(Product::getName, Product::setName);
binder.forField(priceNF)
.withValidator(value -> value != null && value > 0,
"Price must be greater than 0")
.bind(
product -> product.getPrice() != null
? product.getPrice().doubleValue() : null,
(product, value) -> product.setPrice(
value != null ? java.math.BigDecimal.valueOf(value) : null)
);
binder.forField(stockNF)
.withValidator(value -> value != null && value >= 0,
"Stock must not be negative")
.bind(
product -> (double) product.getStock(),
(product, value) -> product.setStock(value != null ? value.intValue() : 0)
);
binder.forField(categoryCB)
.asRequired("Category must be selected")
.bind(Product::getCategory, Product::setCategory);
binder.forField(activeCB)
.bind(Product::isActive, Product::setActive);
}
private void configureButtons() {
saveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
saveBtn.addClickListener(e -> save());
cancelBtn.addClickListener(e -> cancel());
}
// Fill the form with the product being edited
public void setProduct(Product product) {
this.currentProduct = product;
binder.readBean(product); // populate the fields from the object
setVisible(true);
}
// Clear the form for adding a new product
public void setNewProduct() {
this.currentProduct = new Product();
binder.readBean(currentProduct);
setVisible(true);
}
private void save() {
try {
// Validate all fields and write the values to the model object
binder.writeBean(currentProduct);
productService.save(currentProduct);
Notification.show("Product saved successfully")
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
setVisible(false);
// Notify the parent view to refresh the data
fireEvent(new ProductSavedEvent(this, currentProduct));
} catch (ValidationException e) {
// The Binder automatically shows inline errors on each field
Notification.show("Please check the entered data")
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void cancel() {
binder.readBean(currentProduct); // reset to the initial values
setVisible(false);
}
// Custom event for component-to-component communication
public static class ProductSavedEvent extends com.vaadin.flow.component.ComponentEvent<ProductForm> {
private final Product product;
public ProductSavedEvent(ProductForm source, Product product) {
super(source, false);
this.product = product;
}
public Product getProduct() { return product; }
}
}
A Complete CRUD View #
Combining a Grid and a Form into one complete view — a very common pattern in Vaadin applications:
@Route(value = "products", layout = MainLayout.class)
@PageTitle("Product Management")
public class ProductView extends HorizontalLayout {
private final Grid<Product> grid = new Grid<>(Product.class, false);
private final ProductForm form;
private final ProductService productService;
// TextField for filtering
private final TextField filterTF = new TextField();
public ProductView(ProductService productService) {
this.productService = productService;
this.form = new ProductForm(productService);
setSizeFull();
configureGrid();
configureFilter();
configureForm();
// Layout: grid on the left, form on the right
VerticalLayout gridContent = new VerticalLayout(buildToolbar(), grid);
gridContent.setSizeFull();
add(gridContent, form);
setFlexGrow(2, gridContent); // the grid gets 2/3 of the width
setFlexGrow(1, form); // the form gets 1/3 of the width
updateList();
closeForm();
}
private com.vaadin.flow.component.Component buildToolbar() {
filterTF.setPlaceholder("Search products...");
filterTF.setClearButtonVisible(true);
filterTF.setPrefixComponent(
new com.vaadin.flow.component.icon.Icon("lumo", "search"));
filterTF.setValueChangeMode(
com.vaadin.flow.data.value.ValueChangeMode.LAZY);
filterTF.addValueChangeListener(e -> updateList());
Button addBtn = new Button("Add Product",
new com.vaadin.flow.component.icon.Icon("lumo", "plus"));
addBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
addBtn.addClickListener(e -> addProduct());
return new HorizontalLayout(filterTF, addBtn);
}
private void configureGrid() {
grid.setSizeFull();
grid.addColumn(Product::getName).setHeader("Name").setSortable(true).setFlexGrow(2);
grid.addColumn(Product::getCategory).setHeader("Category").setSortable(true);
grid.addColumn(Product::getPrice).setHeader("Price").setSortable(true);
grid.addColumn(Product::getStock).setHeader("Stock").setSortable(true);
// Click a row to edit
grid.asSingleSelect().addValueChangeListener(event -> {
if (event.getValue() != null) {
editProduct(event.getValue());
} else {
closeForm();
}
});
}
private void configureFilter() {
// The filter is already configured in buildToolbar
}
private void configureForm() {
form.setWidth("380px");
// Listen for events from the form
form.addListener(ProductForm.ProductSavedEvent.class, e -> {
updateList();
closeForm();
});
}
private void updateList() {
String filter = filterTF.getValue();
if (filter.isBlank()) {
grid.setItems(productService.getAllProducts());
} else {
grid.setItems(productService.searchByName(filter));
}
}
private void addProduct() {
grid.asSingleSelect().clear();
form.setNewProduct();
form.setVisible(true);
}
private void editProduct(Product product) {
form.setProduct(product);
form.setVisible(true);
}
private void closeForm() {
form.setVisible(false);
grid.asSingleSelect().clear();
}
}
Spring Boot Integration #
Vaadin integrates very closely with Spring Boot. Views can be Spring Beans and receive dependency injection like services and repositories.
import org.springframework.context.annotation.Scope;
import com.vaadin.flow.spring.annotation.SpringComponent;
import com.vaadin.flow.spring.annotation.UIScope;
// @UIScope — one instance per browser tab (follows the Vaadin UI lifecycle)
// @VaadinSessionScope — one instance per browser session
// @SpringComponent is equivalent to @Component but specific to Vaadin
@Route("reports")
@PageTitle("Sales Report")
public class ReportView extends VerticalLayout {
// Constructor injection works normally in Views
private final SalesService salesService;
private final ProductService productService;
public ReportView(SalesService salesService, ProductService productService) {
this.salesService = salesService;
this.productService = productService;
buildUI();
}
private void buildUI() {
add(new H1("Sales Report"));
// Use the injected services
var data = salesService.getMonthlySummary();
var grid = new Grid<SalesSummary>(SalesSummary.class, true);
grid.setItems(data);
add(grid);
}
}
Push — Updating the UI from Background Threads #
When data changes in the background (from another thread, a schedule, or an event), you need to tell Vaadin to update the UI. Use @Push and UI.access():
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.shared.communication.PushMode;
// @Push must be on the UI class or MainLayout
@Push(PushMode.AUTOMATIC)
@Route("monitor")
@PageTitle("Real-time Monitor")
public class MonitorView extends VerticalLayout {
private final com.vaadin.flow.component.html.Span statusSpan = new com.vaadin.flow.component.html.Span("Waiting for data...");
private final com.vaadin.flow.component.UI ui;
public MonitorView() {
this.ui = com.vaadin.flow.component.UI.getCurrent();
add(new H1("System Status"), statusSpan);
}
// Called from a background thread (scheduler, event listener, etc.)
public void updateStatus(String newStatus) {
// UI.access() ensures the update is thread-safe
ui.access(() -> {
statusSpan.setText(newStatus);
// Vaadin automatically pushes the change to the browser with @Push(AUTOMATIC)
});
}
}
Testing Views #
import com.vaadin.testbench.TestBenchTestCase;
import com.vaadin.flow.component.button.testbench.ButtonElement;
import com.vaadin.flow.component.textfield.testbench.TextFieldElement;
import com.vaadin.flow.component.grid.testbench.GridElement;
// Unit tests without a browser — use Vaadin testing utilities
import com.vaadin.flow.component.UI;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class ProductFormTest {
private ProductForm form;
private ProductService mockService;
@BeforeEach
void setUp() {
// Create a mock UI for testing
UI ui = new UI();
UI.setCurrent(ui);
mockService = mock(ProductService.class);
form = new ProductForm(mockService);
}
@Test
void form_whenFilledCompletely_saveSucceeds() {
// Arrange
Product newProduct = new Product();
form.setNewProduct();
// Access the fields via the internal API (for unit tests)
// In integration tests use TestBench with a real browser
// Set values via the Binder
form.nameTF.setValue("Gaming Laptop");
form.priceNF.setValue(15000000.0);
form.stockNF.setValue(5.0);
form.categoryCB.setValue("Electronics");
// Act — click the save button
form.saveBtn.click();
// Assert
verify(mockService, times(1)).save(any(Product.class));
}
@Test
void form_whenNameIsEmpty_doesNotCall_save() {
form.setNewProduct();
form.nameTF.setValue(""); // empty name
form.saveBtn.click();
verify(mockService, never()).save(any());
assertThat(form.nameTF.isInvalid()).isTrue();
}
}
When to Use Vaadin and When Not To #
USE VAADIN WHEN:
✓ The team is pure Java developers without frontend expertise
✓ Internal applications, admin dashboards, or back-office systems
✓ Medium UI complexity — forms, tables, filters, CRUD
✓ You need type-safety between the UI and business logic
✓ Deep Spring Boot ecosystem integration
✓ Rapid enterprise application prototyping
✓ Security matters — no API is exposed to the browser
CONSIDER ALTERNATIVES WHEN:
✗ Public applications with highly custom UI → React/Vue is more flexible
✗ Mobile-first or high-performance client needs → modern SPAs fit better
✗ The team is already expert in JavaScript frameworks → use existing expertise
✗ Many concurrent active users → server-side state = lots of memory
✗ You need offline mode or complex PWAs → Vaadin is limited here
✗ Very complex UI interactions (custom animations, canvas, WebGL) → JS is more natural
flowchart TD
A{"Pure Java team\nwithout frontend?"} -- Yes --> B{"Internal application\nor back-office?"}
A -- No --> C{"Need a highly\ncustom UI?"}
B -- Yes --> VAADIN[Vaadin]
B -- No --> D{"Many concurrent\nactive users?"}
D -- Yes --> SPA["React / Vue / Angular"]
D -- No --> VAADIN
C -- Yes --> SPA
C -- No --> E{"Java or\nJavaScript?"}
E -- Java is stronger --> VAADIN
E -- JavaScript is stronger --> SPASummary #
- Vaadin keeps the UI state on the server — every browser tab has its own UI instance, and all events and state are managed by Java on the server, not JavaScript in the browser.
@Routedefines the URL for a View, andMainLayoutenables sharing common chrome (navbar, sidebar) across views without code duplication.- Grid is the most important component for tabular data — always define columns explicitly (
falsein the constructor) and use lazy loading for large datasets.Binderis the idiomatic Vaadin way to connect UI fields to model objects — it handles validation, type conversion, and two-way binding all at once. Safer than reading field values one by one.- Spring Boot constructor injection works directly in Vaadin Views — just declare a constructor with service parameters and Spring injects them automatically.
UI.access()is required when updating the UI from a background thread — without it the change isn’t thread-safe and can cause race conditions. Pair it with@Pushon the layout.- A CRUD view with Grid + Form is the most common pattern — Grid on the left for the data list, Form on the right for editing, both communicating through custom events or callbacks.
- Vaadin fits best for internal applications, back-offices, and pure Java teams that want high productivity without learning the modern frontend ecosystem.