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 typesusernameField.addValueChangeListener { event -> usernameSignal.value = event.value}
// State → UI: Update field when signal changesusernameSignal.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.Componentimport com.vaadin.flow.component.textfield.TextFieldimport dev.hilla.Nonnullimport kotlin.reflect.KMutableProperty0
// Two-way binding: UI ↔ Statefun 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 automaticThe 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.Buttonimport com.vaadin.flow.component.html.H3import com.vaadin.flow.component.orderedlayout.VerticalLayoutimport com.vaadin.flow.component.textfield.PasswordFieldimport com.vaadin.flow.component.textfield.TextFieldimport com.vaadin.flow.router.Routeimport dev.hilla.Nonnullimport reactor.core.publisher.Signalimport reactor.core.publisher.MutableSignal
// Form data modeldata 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 signalsfun <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 directionusernameField.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