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 DOM

Components 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| VDOM
Because 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 --> SPA

Summary #

  • 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.
  • @Route defines the URL for a View, and MainLayout enables sharing common chrome (navbar, sidebar) across views without code duplication.
  • Grid is the most important component for tabular data — always define columns explicitly (false in the constructor) and use lazy loading for large datasets.
  • Binder is 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 @Push on 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.

← Previous: Spring Boot   Next: Play Framework →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact