Vaadin

Vaadin #

Membangun antarmuka web untuk aplikasi Java enterprise biasanya berarti memisahkan dua dunia: tim backend menulis Java, tim frontend menulis JavaScript, dan keduanya harus berkoordinasi melalui REST API. Vaadin menawarkan pendekatan yang sama sekali berbeda — kamu membangun seluruh UI dalam Java murni, tanpa menyentuh HTML, CSS, atau JavaScript secara langsung. Setiap komponen UI adalah objek Java, event handler adalah method Java, dan data binding dilakukan melalui tipe yang aman di waktu kompilasi. Di balik layar, Vaadin menangani komunikasi antara server dan browser melalui WebSocket, merender perubahan UI secara efisien, dan menghasilkan HTML yang bisa diakses. Pendekatan ini menjadikan Vaadin pilihan yang sangat produktif untuk tim Java yang perlu membangun aplikasi internal, dashboard admin, atau sistem CRUD yang kompleks tanpa harus menguasai ekosistem frontend modern.

Arsitektur Vaadin #

Memahami bagaimana Vaadin bekerja di balik layar penting untuk menulis aplikasi yang performan dan menghindari jebakan umum.

Server-Side Rendering dan State #

Vaadin menjaga state UI di server. Setiap browser session memiliki satu instance UI yang berjalan di server. Ketika pengguna mengklik tombol, event dikirimkan ke server melalui WebSocket, server memproses event dan memperbarui state UI, lalu perubahan dikirim kembali ke browser sebagai differential update — hanya bagian yang berubah yang dikirim, bukan seluruh halaman.

sequenceDiagram
    participant Browser
    participant Server as Vaadin Server\n(Java)

    Browser->>Server: HTTP request — akses /dashboard
    Server-->>Browser: HTML awal + Vaadin JS bundle

    Browser->>Server: WebSocket: klik tombol "Simpan"
    Server->>Server: Jalankan event handler Java\nperbarui state UI
    Server-->>Browser: WebSocket: differential update\n(hanya bagian yang berubah)
    Browser->>Browser: Perbarui DOM

Komponen dan Virtual DOM Server-Side #

Vaadin memiliki virtual DOM di sisi server. Ketika kamu memanggil button.setText("Disimpan"), Vaadin tidak langsung mengirim update ke browser — ia mencatat perubahan, mengumpulkan semua perubahan dalam satu request-response cycle, lalu mengirimkan diff yang efisien.

flowchart TD
    subgraph Server[Server Java]
        UI[UI Instance\nper session]
        VDOM[Virtual DOM\nServer-side]
        COMP[Komponen Java\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 -->|event: klik, input| VDOM
Karena state ada di server, setiap tab browser yang terbuka adalah session terpisah dengan instance UI yang berbeda. Ini berarti kamu tidak perlu mengelola state secara manual di sisi client — tapi juga berarti setiap pengguna aktif menggunakan memori di server.

Setup Project #

Cara termudah membuat project Vaadin baru adalah melalui start.vaadin.com atau Spring Initializr dengan dependency Vaadin.

<!-- 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 untuk akses database -->
    <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>

Struktur direktori project Vaadin:

src/
├── main/
│   ├── java/
│   │   └── com/contoh/app/
│   │       ├── Application.java
│   │       ├── views/           ← View (halaman UI)
│   │       │   ├── MainView.java
│   │       │   └── produk/
│   │       │       └── ProdukView.java
│   │       ├── service/         ← logika bisnis
│   │       └── data/            ← entity dan repository
│   └── resources/
│       ├── application.properties
│       └── META-INF/
│           └── resources/
│               └── frontend/    ← CSS kustom (opsional)

View dan Routing #

Di Vaadin, setiap “halaman” adalah sebuah View — kelas Java yang dianotasi dengan @Route. Vaadin Router menangani navigasi antar view tanpa reload halaman penuh.

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("") → halaman utama (URL: /)
// @Route("produk") → URL: /produk
// @Route("admin/pengguna") → URL: /admin/pengguna
@Route("")
@PageTitle("Beranda | Toko Online")
public class BerandaView extends VerticalLayout {

    public BerandaView() {
        // Semua komponen ditambahkan di constructor
        H1 judul = new H1("Selamat Datang di Toko Online");
        Paragraph deskripsi = new Paragraph(
            "Kelola produk, pesanan, dan pelanggan dari satu tempat."
        );

        // add() menambahkan komponen ke layout ini
        add(judul, deskripsi);

        // Styling via Java API
        setSizeFull();
        setAlignItems(Alignment.CENTER);
        setJustifyContentMode(JustifyContentMode.CENTER);
    }
}

Layout Utama (Main Layout) #

View yang berbagi tampilan umum (navbar, sidebar) bisa menggunakan layout bersama:

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 membuat kelas ini sebagai layout untuk semua view di package yang sama
@Layout
public class MainLayout extends AppLayout {

    public MainLayout() {
        // Navbar atas
        DrawerToggle toggle = new DrawerToggle();
        H2 appName = new H2("Toko Online");
        appName.getStyle().set("font-size", "var(--lumo-font-size-l)")
                         .set("margin", "0");
        addToNavbar(toggle, appName);

        // Sidebar navigasi
        SideNav nav = new SideNav();
        nav.addItem(new SideNavItem("Beranda", BerandaView.class,
            new com.vaadin.flow.component.icon.VaadinIcon.HOME.create()));
        nav.addItem(new SideNavItem("Produk", ProdukView.class,
            new com.vaadin.flow.component.icon.VaadinIcon.PACKAGE.create()));
        nav.addItem(new SideNavItem("Pesanan", PesananView.class,
            new com.vaadin.flow.component.icon.VaadinIcon.CART.create()));

        addToDrawer(nav);
    }
}

// View dengan layout — cukup tentukan layout di @Route
@Route(value = "produk", layout = MainLayout.class)
@PageTitle("Produk | Toko Online")
public class ProdukView extends VerticalLayout {
    // konten view...
}
import com.vaadin.flow.component.UI;
import com.vaadin.flow.router.RouteParameters;

// Navigasi ke route tertentu
UI.getCurrent().navigate(ProdukView.class);

// Navigasi dengan parameter URL
UI.getCurrent().navigate(ProdukDetailView.class, new RouteParameters("id", "42"));

// Navigasi dengan URL string (untuk kasus kompleks)
UI.getCurrent().navigate("produk/42/edit");

Komponen UI Dasar #

Vaadin menyediakan library komponen yang kaya. Berikut komponen yang paling sering digunakan.

Input dan Form Sederhana #

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 KomponenDasarDemo extends VerticalLayout {

    public KomponenDasarDemo() {

        // TextField — input teks
        TextField namaTF = new TextField("Nama Produk");
        namaTF.setPlaceholder("Masukkan nama produk...");
        namaTF.setRequired(true);
        namaTF.setMinLength(3);
        namaTF.setMaxLength(100);
        namaTF.setWidth("300px");

        // PasswordField
        PasswordField passwordTF = new PasswordField("Password");

        // NumberField — input angka
        NumberField hargaNF = new NumberField("Harga");
        hargaNF.setPrefixComponent(new com.vaadin.flow.component.html.Span("Rp"));
        hargaNF.setMin(0);
        hargaNF.setStep(1000);

        // ComboBox — dropdown dengan search
        ComboBox<String> kategoriCB = new ComboBox<>("Kategori");
        kategoriCB.setItems("Elektronik", "Pakaian", "Makanan", "Olahraga");
        kategoriCB.setPlaceholder("Pilih kategori");

        // DatePicker
        DatePicker tanggalDP = new DatePicker("Tanggal Kadaluarsa");
        tanggalDP.setMin(java.time.LocalDate.now());

        // Checkbox
        Checkbox aktifCB = new Checkbox("Produk aktif");
        aktifCB.setValue(true);

        // Button dengan variant styling
        Button simpanBtn = new Button("Simpan");
        simpanBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

        Button batalBtn = new Button("Batal");
        batalBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);

        // Event handler — klik tombol
        simpanBtn.addClickListener(event -> {
            String nama = namaTF.getValue();
            if (nama.isBlank()) {
                // Tampilkan error inline di field
                namaTF.setErrorMessage("Nama tidak boleh kosong");
                namaTF.setInvalid(true);
                return;
            }
            // Tampilkan notifikasi
            Notification notif = Notification.show("Produk '" + nama + "' berhasil disimpan!");
            notif.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
            notif.setPosition(Notification.Position.BOTTOM_END);
        });

        HorizontalLayout tombolLayout = new HorizontalLayout(simpanBtn, batalBtn);

        add(namaTF, hargaNF, kategoriCB, tanggalDP, aktifCB, tombolLayout);
        setSpacing(true);
        setPadding(true);
    }
}

Dialog dan Konfirmasi #

import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;

public class DialogDemo {

    // Dialog kustom
    public static Dialog buatDialogForm(String judul) {
        Dialog dialog = new Dialog();
        dialog.setHeaderTitle(judul);
        dialog.setWidth("500px");
        dialog.setCloseOnEsc(true);
        dialog.setCloseOnOutsideClick(false);

        // Konten dialog
        VerticalLayout konten = new VerticalLayout();
        TextField namaTF = new TextField("Nama");
        konten.add(namaTF);
        dialog.add(konten);

        // Footer dengan tombol
        Button simpan = new Button("Simpan", e -> {
            // proses penyimpanan
            dialog.close();
        });
        simpan.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

        Button tutup = new Button("Tutup", e -> dialog.close());
        dialog.getFooter().add(tutup, simpan);

        return dialog;
    }

    // ConfirmDialog — dialog konfirmasi hapus
    public static void konfirmasiHapus(String namaProduk, Runnable onKonfirmasi) {
        ConfirmDialog dialog = new ConfirmDialog();
        dialog.setHeader("Hapus Produk?");
        dialog.setText("Apakah kamu yakin ingin menghapus '" + namaProduk + "'? " +
                        "Tindakan ini tidak bisa dibatalkan.");

        dialog.setCancelable(true);
        dialog.setCancelText("Batal");

        dialog.setConfirmText("Hapus");
        dialog.setConfirmButtonTheme("error primary");
        dialog.addConfirmListener(event -> onKonfirmasi.run());

        dialog.open();
    }
}

Grid — Tabel Data #

Grid adalah komponen paling penting di Vaadin untuk menampilkan data tabular. Ia mendukung lazy loading, sorting, filtering, dan 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 ProdukGrid extends VerticalLayout {

    private final Grid<Produk> grid;
    private final ProdukService produkService;

    public ProdukGrid(ProdukService produkService) {
        this.produkService = produkService;

        grid = new Grid<>(Produk.class, false); // false = tidak auto-generate kolom
        configureGrid();
        loadData();

        add(grid);
        setSizeFull();
    }

    private void configureGrid() {
        grid.setSizeFull();
        grid.addThemeVariants(GridVariant.LUMO_ROW_STRIPES, GridVariant.LUMO_BORDERED);

        // Definisi kolom secara eksplisit
        grid.addColumn(Produk::getNama)
            .setHeader("Nama Produk")
            .setSortable(true)
            .setFlexGrow(2)   // kolom ini dapat 2x lebih banyak ruang
            .setResizable(true);

        grid.addColumn(new NumberRenderer<>(
                Produk::getHarga,
                NumberFormat.getCurrencyInstance(new Locale("id", "ID"))
            ))
            .setHeader("Harga")
            .setSortable(true)
            .setTextAlign(com.vaadin.flow.component.grid.ColumnTextAlign.END)
            .setFlexGrow(1);

        grid.addColumn(Produk::getStok)
            .setHeader("Stok")
            .setSortable(true)
            .setFlexGrow(0)
            .setWidth("100px");

        // Kolom dengan komponen kustom — badge status stok
        grid.addColumn(new ComponentRenderer<>(produk -> {
            com.vaadin.flow.component.html.Span badge = new com.vaadin.flow.component.html.Span(
                produk.getStok() > 0 ? "Tersedia" : "Habis"
            );
            badge.getElement().getThemeList().add(
                "badge " + (produk.getStok() > 0 ? "success" : "error")
            );
            return badge;
        }))
        .setHeader("Status")
        .setFlexGrow(0)
        .setWidth("120px");

        // Kolom aksi — tombol edit dan hapus
        grid.addColumn(new ComponentRenderer<>(produk -> {
            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 -> bukaFormEdit(produk));

            Button hapusBtn = new Button("Hapus",
                new com.vaadin.flow.component.icon.Icon("lumo", "cross"));
            hapusBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY,
                ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
            hapusBtn.addClickListener(e ->
                DialogDemo.konfirmasiHapus(produk.getNama(), () -> hapusProduk(produk))
            );

            return new HorizontalLayout(editBtn, hapusBtn);
        }))
        .setHeader("Aksi")
        .setFlexGrow(0)
        .setWidth("180px");

        // Selection mode — single selection
        grid.setSelectionMode(Grid.SelectionMode.SINGLE);
        grid.addSelectionListener(event ->
            event.getFirstSelectedItem().ifPresent(this::tampilkanDetail)
        );
    }

    private void loadData() {
        grid.setItems(produkService.semuaProduk());
    }

    // Untuk dataset besar — gunakan lazy loading
    private void loadDataLazy() {
        grid.setItems(query -> {
            // Hanya load data yang terlihat — efisien untuk ribuan row
            int offset = query.getOffset();
            int limit = query.getLimit();
            return produkService.findAll(offset, limit).stream();
        });
    }

    private void bukaFormEdit(Produk produk) {
        // buka dialog atau navigasi ke halaman edit
    }

    private void hapusProduk(Produk produk) {
        produkService.hapus(produk.getId());
        loadData(); // refresh grid
        Notification.show("Produk berhasil dihapus").addThemeVariants(NotificationVariant.LUMO_SUCCESS);
    }

    private void tampilkanDetail(Produk produk) {
        // tampilkan panel detail di samping grid
    }
}

Data Binding dengan Binder #

Binder adalah mekanisme Vaadin untuk menghubungkan field UI dengan objek model Java secara dua arah, lengkap dengan validasi.

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 ProdukForm extends VerticalLayout {

    private final Binder<Produk> binder = new Binder<>(Produk.class);
    private Produk produkSaatIni;

    private final TextField namaTF = new TextField("Nama Produk");
    private final NumberField hargaNF = new NumberField("Harga");
    private final NumberField stokNF = new NumberField("Stok");
    private final ComboBox<String> kategoriCB = new ComboBox<>("Kategori");
    private final Checkbox aktifCB = new Checkbox("Aktif");

    private final Button simpanBtn = new Button("Simpan");
    private final Button batalBtn = new Button("Batal");

    private final ProdukService produkService;

    public ProdukForm(ProdukService produkService) {
        this.produkService = produkService;

        configureFields();
        configureBinder();
        configureButtons();

        add(namaTF, hargaNF, stokNF, kategoriCB, aktifCB,
            new HorizontalLayout(simpanBtn, batalBtn));
        setWidth("400px");
        setPadding(true);
        setSpacing(true);
    }

    private void configureFields() {
        kategoriCB.setItems("Elektronik", "Pakaian", "Makanan", "Olahraga");
        hargaNF.setPrefixComponent(new com.vaadin.flow.component.html.Span("Rp"));
        hargaNF.setMin(0);
        stokNF.setMin(0);
        stokNF.setStep(1);
    }

    private void configureBinder() {
        // Binding field ke property model dengan validasi
        binder.forField(namaTF)
            .withValidator(new StringLengthValidator(
                "Nama harus 3-100 karakter", 3, 100))
            .bind(Produk::getNama, Produk::setNama);

        binder.forField(hargaNF)
            .withValidator(nilai -> nilai != null && nilai > 0,
                "Harga harus lebih dari 0")
            .bind(
                produk -> produk.getHarga() != null
                    ? produk.getHarga().doubleValue() : null,
                (produk, nilai) -> produk.setHarga(
                    nilai != null ? java.math.BigDecimal.valueOf(nilai) : null)
            );

        binder.forField(stokNF)
            .withValidator(nilai -> nilai != null && nilai >= 0,
                "Stok tidak boleh negatif")
            .bind(
                produk -> (double) produk.getStok(),
                (produk, nilai) -> produk.setStok(nilai != null ? nilai.intValue() : 0)
            );

        binder.forField(kategoriCB)
            .asRequired("Kategori harus dipilih")
            .bind(Produk::getKategori, Produk::setKategori);

        binder.forField(aktifCB)
            .bind(Produk::isAktif, Produk::setAktif);
    }

    private void configureButtons() {
        simpanBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        batalBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);

        simpanBtn.addClickListener(e -> simpan());
        batalBtn.addClickListener(e -> batal());
    }

    // Isi form dengan data produk yang akan diedit
    public void setProduk(Produk produk) {
        this.produkSaatIni = produk;
        binder.readBean(produk); // populate field dari objek
        setVisible(true);
    }

    // Kosongkan form untuk tambah produk baru
    public void setNovoProduk() {
        this.produkSaatIni = new Produk();
        binder.readBean(produkSaatIni);
        setVisible(true);
    }

    private void simpan() {
        try {
            // Validasi semua field dan tulis nilai ke objek model
            binder.writeBean(produkSaatIni);
            produkService.simpan(produkSaatIni);

            Notification.show("Produk berhasil disimpan")
                .addThemeVariants(NotificationVariant.LUMO_SUCCESS);
            setVisible(false);

            // Beritahu parent view untuk refresh data
            fireEvent(new ProdukDisimpanEvent(this, produkSaatIni));

        } catch (ValidationException e) {
            // Binder otomatis menampilkan error inline di setiap field
            Notification.show("Periksa kembali data yang dimasukkan")
                .addThemeVariants(NotificationVariant.LUMO_ERROR);
        }
    }

    private void batal() {
        binder.readBean(produkSaatIni); // reset ke nilai awal
        setVisible(false);
    }

    // Custom event untuk komunikasi antar komponen
    public static class ProdukDisimpanEvent extends com.vaadin.flow.component.ComponentEvent<ProdukForm> {
        private final Produk produk;
        public ProdukDisimpanEvent(ProdukForm source, Produk produk) {
            super(source, false);
            this.produk = produk;
        }
        public Produk getProduk() { return produk; }
    }
}

View CRUD Lengkap #

Menggabungkan Grid dan Form dalam satu view yang lengkap — pola yang sangat umum di aplikasi Vaadin:

@Route(value = "produk", layout = MainLayout.class)
@PageTitle("Manajemen Produk")
public class ProdukView extends HorizontalLayout {

    private final Grid<Produk> grid = new Grid<>(Produk.class, false);
    private final ProdukForm form;
    private final ProdukService produkService;

    // TextField untuk filtering
    private final TextField filterTF = new TextField();

    public ProdukView(ProdukService produkService) {
        this.produkService = produkService;
        this.form = new ProdukForm(produkService);

        setSizeFull();
        configureGrid();
        configureFilter();
        configureForm();

        // Layout: grid di kiri, form di kanan
        VerticalLayout kontenGrid = new VerticalLayout(buildToolbar(), grid);
        kontenGrid.setSizeFull();

        add(kontenGrid, form);
        setFlexGrow(2, kontenGrid);  // grid dapat 2/3 lebar
        setFlexGrow(1, form);        // form dapat 1/3 lebar

        updateList();
        tutupForm();
    }

    private com.vaadin.flow.component.Component buildToolbar() {
        filterTF.setPlaceholder("Cari produk...");
        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 tambahBtn = new Button("Tambah Produk",
            new com.vaadin.flow.component.icon.Icon("lumo", "plus"));
        tambahBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        tambahBtn.addClickListener(e -> tambahProduk());

        return new HorizontalLayout(filterTF, tambahBtn);
    }

    private void configureGrid() {
        grid.setSizeFull();
        grid.addColumn(Produk::getNama).setHeader("Nama").setSortable(true).setFlexGrow(2);
        grid.addColumn(Produk::getKategori).setHeader("Kategori").setSortable(true);
        grid.addColumn(Produk::getHarga).setHeader("Harga").setSortable(true);
        grid.addColumn(Produk::getStok).setHeader("Stok").setSortable(true);

        // Klik baris untuk edit
        grid.asSingleSelect().addValueChangeListener(event -> {
            if (event.getValue() != null) {
                editProduk(event.getValue());
            } else {
                tutupForm();
            }
        });
    }

    private void configureFilter() {
        // Filter sudah dikonfigurasi di buildToolbar
    }

    private void configureForm() {
        form.setWidth("380px");
        // Dengarkan event dari form
        form.addListener(ProdukForm.ProdukDisimpanEvent.class, e -> {
            updateList();
            tutupForm();
        });
    }

    private void updateList() {
        String filter = filterTF.getValue();
        if (filter.isBlank()) {
            grid.setItems(produkService.semuaProduk());
        } else {
            grid.setItems(produkService.cariByNama(filter));
        }
    }

    private void tambahProduk() {
        grid.asSingleSelect().clear();
        form.setNovoProduk();
        form.setVisible(true);
    }

    private void editProduk(Produk produk) {
        form.setProduk(produk);
        form.setVisible(true);
    }

    private void tutupForm() {
        form.setVisible(false);
        grid.asSingleSelect().clear();
    }
}

Integrasi Spring Boot #

Vaadin berintegrasi sangat erat dengan Spring Boot. View bisa menjadi Spring Bean dan menerima dependency injection seperti service dan repository.

import org.springframework.context.annotation.Scope;
import com.vaadin.flow.spring.annotation.SpringComponent;
import com.vaadin.flow.spring.annotation.UIScope;

// @UIScope — satu instance per browser tab (mengikuti lifecycle Vaadin UI)
// @VaadinSessionScope — satu instance per browser session
// @SpringComponent setara dengan @Component tapi khusus untuk Vaadin
@Route("laporan")
@PageTitle("Laporan Penjualan")
public class LaporanView extends VerticalLayout {

    // Constructor injection bekerja normal di View
    private final PenjualanService penjualanService;
    private final ProdukService produkService;

    public LaporanView(PenjualanService penjualanService, ProdukService produkService) {
        this.penjualanService = penjualanService;
        this.produkService = produkService;
        buildUI();
    }

    private void buildUI() {
        add(new H1("Laporan Penjualan"));

        // Gunakan service yang di-inject
        var data = penjualanService.getRekapBulanIni();
        var grid = new Grid<RekapPenjualan>(RekapPenjualan.class, true);
        grid.setItems(data);
        add(grid);
    }
}

Push — Update UI dari Background Thread #

Ketika data berubah di background (dari thread lain, schedule, atau event), kamu perlu memberitahu Vaadin untuk memperbarui UI. Gunakan @Push dan UI.access():

import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.shared.communication.PushMode;

// @Push harus ada di kelas UI atau MainLayout
@Push(PushMode.AUTOMATIC)
@Route("monitor")
@PageTitle("Monitor Real-time")
public class MonitorView extends VerticalLayout {

    private final com.vaadin.flow.component.html.Span statusSpan = new com.vaadin.flow.component.html.Span("Menunggu data...");
    private final com.vaadin.flow.component.UI ui;

    public MonitorView() {
        this.ui = com.vaadin.flow.component.UI.getCurrent();
        add(new H1("Status Sistem"), statusSpan);
    }

    // Dipanggil dari background thread (scheduler, event listener, dll)
    public void perbaruiStatus(String statusBaru) {
        // UI.access() memastikan update thread-safe
        ui.access(() -> {
            statusSpan.setText(statusBaru);
            // Vaadin otomatis push perubahan ke browser karena @Push(AUTOMATIC)
        });
    }
}

Pengujian View #

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 test tanpa browser — gunakan 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 ProdukFormTest {

    private ProdukForm form;
    private ProdukService mockService;

    @BeforeEach
    void setUp() {
        // Buat mock UI untuk test
        UI ui = new UI();
        UI.setCurrent(ui);

        mockService = mock(ProdukService.class);
        form = new ProdukForm(mockService);
    }

    @Test
    void form_ketikaDiisiLengkap_simpanBerhasil() {
        // Arrange
        Produk produkBaru = new Produk();
        form.setNovoProduk();

        // Akses field via API internal (untuk unit test)
        // Dalam integration test gunakan TestBench dengan browser sungguhan

        // Set nilai via Binder
        form.namaTF.setValue("Laptop Gaming");
        form.hargaNF.setValue(15000000.0);
        form.stokNF.setValue(5.0);
        form.kategoriCB.setValue("Elektronik");

        // Act — klik tombol simpan
        form.simpanBtn.click();

        // Assert
        verify(mockService, times(1)).simpan(any(Produk.class));
    }

    @Test
    void form_ketikaNamaKosong_tidakMemanggil_simpan() {
        form.setNovoProduk();
        form.namaTF.setValue(""); // nama kosong

        form.simpanBtn.click();

        verify(mockService, never()).simpan(any());
        assertThat(form.namaTF.isInvalid()).isTrue();
    }
}

Kapan Menggunakan Vaadin dan Kapan Tidak #

GUNAKAN VAADIN JIKA:
  ✓ Tim adalah Java developer murni tanpa keahlian frontend
  ✓ Aplikasi internal, dashboard admin, atau sistem back-office
  ✓ Kompleksitas UI sedang — form, tabel, filter, CRUD
  ✓ Butuh type-safety antara UI dan logika bisnis
  ✓ Integrasi Spring Boot ekosistem yang dalam
  ✓ Prototyping cepat aplikasi enterprise
  ✓ Keamanan penting — tidak ada API yang terekspos ke browser

PERTIMBANGKAN ALTERNATIF JIKA:
  ✗ Aplikasi publik dengan UI yang sangat custom → React/Vue lebih fleksibel
  ✗ Mobile-first atau butuh performa tinggi di klien → SPA modern lebih cocok
  ✗ Tim sudah expert di JavaScript framework → manfaatkan keahlian yang ada
  ✗ Banyak pengguna aktif bersamaan → state di server = banyak memori
  ✗ Butuh offline mode atau PWA yang kompleks → Vaadin terbatas di sini
  ✗ Interaksi UI sangat kompleks (animasi kustom, canvas, WebGL) → JS lebih natural
flowchart TD
    A{Tim Java murni\ntanpa frontend?} -- Ya --> B{Aplikasi internal\natau back-office?}
    A -- Tidak --> C{Butuh UI\nsangat kustom?}

    B -- Ya --> VAADIN[Vaadin]
    B -- Tidak --> D{Pengguna aktif\nbanyak bersamaan?}
    D -- Ya --> SPA[React / Vue / Angular]
    D -- Tidak --> VAADIN

    C -- Ya --> SPA
    C -- Tidak --> E{Java atau\nJavaScript?}
    E -- Java lebih dikuasai --> VAADIN
    E -- JavaScript lebih dikuasai --> SPA

Ringkasan #

  • Vaadin menjaga state UI di server — setiap browser tab punya instance UI tersendiri, semua event dan state dikelola Java di server, bukan JavaScript di browser.
  • @Route mendefinisikan URL untuk View, dan MainLayout memungkinkan berbagi tampilan umum (navbar, sidebar) lintas view tanpa duplikasi kode.
  • Grid adalah komponen terpenting untuk data tabular — selalu definisikan kolom secara eksplisit (false di constructor) dan gunakan lazy loading untuk dataset besar.
  • Binder adalah cara idiomatic Vaadin untuk menghubungkan field UI ke objek model — ia menangani validasi, konversi tipe, dan two-way binding sekaligus. Lebih aman dari membaca nilai field satu per satu.
  • Constructor injection Spring Boot bekerja langsung di View Vaadin — cukup deklarasikan constructor dengan parameter service, Spring otomatis menginjeksikannya.
  • UI.access() wajib digunakan ketika memperbarui UI dari background thread — tanpa ini perubahan tidak thread-safe dan bisa menyebabkan race condition. Pasangkan dengan @Push di layout.
  • View CRUD dengan Grid + Form adalah pola paling umum — Grid di kiri untuk list data, Form di kanan untuk edit, keduanya berkomunikasi melalui custom event atau callback.
  • Vaadin paling cocok untuk aplikasi internal, back-office, dan tim Java murni yang ingin produktivitas tinggi tanpa mempelajari ekosistem frontend modern.

← Sebelumnya: Spring Boot   Berikutnya: Play Framework →

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