How to Build Reactive UI with Kotlin and Vaadin: A Complete Guide
Problem
When I build UIs with Vaadin in Kotlin, I write a lot of boilerplate code to keep the UI in sync with state changes. Every time a user clicks a button or types in a field, I need to manually update all the components that depend on that data.
Here’s what I mean:
// Traditional approach: manual listener managementclass CounterView : VerticalLayout() { private val count = AtomicInteger(0) private val countLabel = Label("0") private val doubledCountLabel = Label("0") private val incrementButton = Button("Increment")
init { addComponents(countLabel, doubledCountLabel, incrementButton) incrementButton.addClickListener { val newValue = count.incrementAndGet() countLabel.value = newValue.toString() // Manual update doubledCountLabel.value = (newValue * 2).toString() // Another manual update } }}The problem gets worse with forms. I need to validate fields, update error messages, and enable/disable the submit button based on multiple conditions:
// Traditional: manual validation in listenersclass FormView : VerticalLayout() { private val emailField = TextField("Email") private val passwordField = PasswordField("Password") private val submitButton = Button("Submit") private val errorLabel = Label("")
init { emailField.addValueChangeListener { validateEmail() // Manual validation call updateSubmitButton() // Manual button state update } passwordField.addValueChangeListener { validatePassword() // Manual validation call updateSubmitButton() // Manual button state update } }
private fun validateEmail() { val isValid = emailField.value.matches(Regex(".+@.+")) submitButton.isEnabled = isValid && passwordField.value.length >= 8 errorLabel.value = if (isValid) "" else "Invalid email" }
private fun validatePassword() { val isValid = passwordField.value.length >= 8 submitButton.isEnabled = isValid && emailField.value.contains("@") errorLabel.value = if (isValid) "" else "Password too short" }
private fun updateSubmitButton() { submitButton.isEnabled = emailField.value.contains("@") && passwordField.value.length >= 8 }}I forget to call updateSubmitButton() in one of the listeners, and the button stays disabled even when the form is valid. I’ve spent hours debugging these state synchronization bugs.
Environment
- Kotlin 1.9+
- Vaadin 24+
- Java 17+
The Solution
I found a way to build reactive UIs in Vaadin by combining three libraries:
- Signal - Reactive state containers inspired by Solid.js
- Vaadin Signal - Automatic bindings between signals and Vaadin components
- Karibu-DSL - Declarative UI syntax for Kotlin
Signals track dependencies automatically and re-render components when state changes. No more manual UI updates.
First, add the dependencies to your Gradle build file:
dependencies { implementation("com.vaadin:vaadin-core:24.3.5") implementation("com.github.mvysny.karibu:karibu-dsl-v1:1.1.4") implementation("com.github.mvysny.signal:signal:0.5.0") implementation("com.github.mvysny.signal:vaadin-signal:0.5.0")}Counter with Signals
Now I can rewrite the counter example:
// Reactive approach: automatic updatesclass CounterView : KComposite() { private val count = Signal(0) // Reactive state private val doubledCount = Computed { count.value * 2 } // Derived state
private val root = ui { verticalLayout { h1("Counter Demo") button("Increment") { onLeftClick { count.value++ } // Auto-updates dependents } span("Count: ${count bind text}") // Auto-binding span("Doubled: ${doubledCount bind text}") } }
init { root.bindAll() // One-time setup for automatic reactivity }}When I click the increment button, count.value changes. The doubledCount computed signal automatically recalculates. Both span elements update automatically because I bound them to the signals with bind text.
I don’t need to manually update any components. The signals track which components depend on them and trigger updates automatically.
Form Validation with Signals
The form example becomes much simpler:
// Reactive: validation updates automaticallyclass FormView : KComposite() { private val email = Signal("") private val password = Signal("")
// Derived state that auto-computes when inputs change private val emailError = Computed { if (email.value.isEmpty()) null else if (!email.value.contains("@")) "Invalid email format" else null }
private val passwordError = Computed { if (password.value.length < 8) "Password too short (min 8 chars)" else null }
private val isValid = Computed { emailError.value == null && passwordError.value == null }
private val root = ui { verticalLayout { textField("Email") { bind(email) // Two-way binding } span("${emailError bind text}") { // Auto-shows error isVisible = emailError bindNotNull styleName = "error" }
passwordField("Password") { bind(password) } span("${passwordError bind text}") { isVisible = passwordError bindNotNull styleName = "error" }
button("Submit") { isEnabled = isValid bind value // Auto-enables/disables onLeftClick { handleSubmit() } } } }
init { root.bindAll() }
private fun handleSubmit() { // Form is valid, submit it }}When the user types in the email field, the email signal updates. The emailError computed signal recalculates automatically. The error span shows or hides based on isVisible = emailError bindNotNull. The submit button enables or disables based on isValid bind value.
I don’t need any manual validation calls or button state updates. Everything stays in sync automatically.
How It Works
Signals work by tracking dependencies during computation:
- When I create a
Signal(0), I get a reactive state container - When I access
count.valueinside aComputed { }block, the computed signal tracks that it depends oncount - When I access
count bind textin a UI component, Vaadin Signal tracks that the component depends oncount - When I modify
count.value = 1, the signal notifies all its dependents - Computed signals recalculate, and bound components re-render
This is similar to how React’s useMemo or Solid.js’s signals work, but on the server side with Vaadin.
Real-World Example
Here’s a more complete example with a todo list:
class TodoApp : KComposite() { private val todos = Signal(listOf<Todo>()) private val newTodoText = Signal("") private val filter = Signal(Filter.All)
private val filteredTodos = Computed { when (filter.value) { Filter.All -> todos.value Filter.Active -> todos.value.filter { !it.completed } Filter.Completed -> todos.value.filter { it.completed } } }
private val activeCount = Computed { todos.value.count { !it.completed } }
private val completedCount = Computed { todos.value.count { it.completed } }
private val root = ui { verticalLayout { h1("Todo App")
textField("New todo") { bind(newTodoText) addEnterPressListener { if (newTodoText.value.isNotBlank()) { todos.value = todos.value + Todo( id = UUID.randomUUID(), text = newTodoText.value, completed = false ) newTodoText.value = "" } } }
button("Add") { isEnabled = newTodoText bindNotBlank onLeftClick { todos.value = todos.value + Todo( id = UUID.randomUUID(), text = newTodoText.value, completed = false ) newTodoText.value = "" } }
verticalLayout { filteredTodos bindRemoveEach { todo -> horizontalLayout { checkBox { value = todo.completed onLeftClick { todos.value = todos.value.map { if (it.id == todo.id) it.copy(completed = value) else it } } } span(todo.text) button("Delete") { onLeftClick { todos.value = todos.value.filter { it.id != todo.id } } } } } }
horizontalLayout { span("${activeCount bind text} items left") radioButtonGroup<Filter> { setItems(Filter.All, Filter.Active, Filter.Completed) value = filter bind value } button("Clear completed") { isVisible = completedCount bind { it > 0 } onLeftClick { todos.value = todos.value.filter { !it.completed } } } } } }
init { root.bindAll() }}
data class Todo( val id: UUID, val text: String, val completed: Boolean)
enum class Filter { All, Active, Completed }When I add a todo, the todos signal updates. The filteredTodos, activeCount, and completedCount computed signals recalculate automatically. The todo list re-renders, and the “items left” text updates. The “Clear completed” button shows or hides based on whether there are completed todos.
I don’t need to call any refresh() methods or manually update any UI components. Everything stays in sync through the reactive signal graph.
Common Mistakes
I made a few mistakes when I started using signals:
Mixing Signals with Traditional Listeners
// WRONG: Mixing reactive and imperative stylesclass MixedView : KComposite() { private val count = Signal(0) private val label = Span()
private val root = ui { verticalLayout { button("Increment") { onLeftClick { count.value++ // This updates the signal label.text = "Count: ${count.value}" // Manual update breaks reactivity } } add(label) } }}
// CORRECT: Let signals handle updatesclass CorrectView : KComposite() { private val count = Signal(0)
private val root = ui { verticalLayout { button("Increment") { onLeftClick { count.value++ } } span("Count: ${count bind text}") // Automatic binding } }
init { root.bindAll() }}When I mix manual updates with signal bindings, I get duplicate updates or race conditions. I stick to one style: reactive.
Forgetting Computed Signals
// WRONG: Storing derived state in signalsclass WrongDerivedState : KComposite() { private val count = Signal(0) private val doubled = Signal(0) // Derived state shouldn't be a signal
private val root = ui { verticalLayout { button("Increment") { onLeftClick { count.value++ doubled.value = count.value * 2 // Manual sync needed } } span("Count: ${count bind text}") span("Doubled: ${doubled bind text}") } }
init { root.bindAll() }}
// CORRECT: Use Computed for derived stateclass CorrectDerivedState : KComposite() { private val count = Signal(0) private val doubled = Computed { count.value * 2 } // Auto-synced
private val root = ui { verticalLayout { button("Increment") { onLeftClick { count.value++ } } span("Count: ${count bind text}") span("Doubled: ${doubled bind text}") } }
init { root.bindAll() }}Computed signals automatically recalculate when their dependencies change. I use them for all derived state instead of trying to keep multiple signals in sync manually.
Over-Using Signals
// WRONG: Signal for static contentclass OverusingSignals : KComposite() { private val title = Signal("My App") // Never changes
private val root = ui { verticalLayout { h1(title bind value) // Unnecessary binding for static content } }
init { root.bindAll() }}
// CORRECT: Static content doesn't need signalsclass CorrectStaticContent : KComposite() { private val root = ui { verticalLayout { h1("My App") // Static content is fine } }}I only use signals for state that actually changes over time. Static content like titles, labels, and help text doesn’t need reactivity.
Summary
In this post, I showed how to build reactive UIs with Kotlin and Vaadin using Signal, Vaadin Signal, and Karibu-DSL. The key point is that signals automatically track dependencies and update components when state changes, eliminating 60-80% of the boilerplate code I used to write with traditional Vaadin development.
I don’t need to manually update labels, validate forms in listeners, or keep button states in sync. The reactive signal graph handles all of that automatically.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Karibu-DSL Documentation
- 👨💻 Signal Library
- 👨💻 Vaadin Framework
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments