Skip to content
Go back

Torque Spec: Shared Form State and Validation in KMP

by KMP Bits

KMP Bits Cover

The spec for a wheel nut says 120 Nm, and the mechanic tightening it has known that number for years. Nobody in the garage is confused about the figure. The wrench exists anyway, because knowing 120 Nm and producing 120 Nm on the fifth stop of a long day are different problems, and the second one is the one that puts a wheel in the gravel. You set the collar once, and after that the tool clicks at the same point whether you are fresh or eleven hours in.

The knowledge was never the bottleneck. Doing it by hand, correctly, every single time, was.


My validation rules were already shared. That part I had right years ago: ValidateEmailUseCase, ValidatePasswordUseCase, plain Kotlin in commonMain, called from a shared ViewModel that drove both the Compose screen and the SwiftUI one. One rule, one place, both platforms. If you had asked me whether form validation was a solved problem in my apps, I would have said yes.

What I actually had was this, once per form:

data class LoginUiState(
    val email: String = "",
    val emailError: String? = null,
    val password: String = "",
    val passwordError: String? = null,
    val isSubmitting: Boolean = false,
)

class LoginViewModel(
    private val validateEmail: ValidateEmailUseCase,
    private val validatePassword: ValidatePasswordUseCase,
) : ViewModel() {

    private val _state = MutableStateFlow(LoginUiState())
    val state = _state.asStateFlow()

    fun onEmailChange(value: String) {
        _state.update { it.copy(email = value, emailError = validateEmail(value)) }
    }

    fun onPasswordChange(value: String) {
        _state.update { it.copy(password = value, passwordError = validatePassword(value)) }
    }
}

Nothing there is wrong. It is the pattern I would have defended in a review, and it worked. It is also two properties, one handler and one .copy() per field, forever, and a registration screen with five fields is a data class with ten properties and five handlers that all look almost but not quite identical.

Look at emailError = validateEmail(value) for a second. Now imagine typing emailError = validatePassword(value). Both use cases take a String, both return a String?, and the compiler has no opinion whatsoever about which one belongs on which line. I have never shipped that particular bug, but the only thing standing between me and it is that I was paying attention on the day.

Then there is everything the UiState above does not have. No isTouched, so the field either shows “This field is required” before the user has looked at it, or I add a sixth property to track it. No isDirty. No isValidating, because the moment one of those rules needs a server round trip the whole shape changes. And the confirm-password field, which means remembering, inside onPasswordChange, to go back and re-run the match check on a field that is not the one being edited.

Composure is the torque wrench for that. The rules were never the part I got wrong.


What a field actually is

A field is a value, a set of constraints, and some state about how far the user has got with it. composure-core says exactly that, in plain Kotlin, commonMain, with no Compose dependency and no UI framework dependency of any kind.

The constraints hang off a FieldType:

// composure-core, commonMain
interface FieldType {
    val keyboardHint: KeyboardHint get() = KeyboardHint.Text
    val isSecret: Boolean get() = false
    val defaultValidators: List<FieldValidator> get() = emptyList()
}

object Email : FieldType {
    override val keyboardHint = KeyboardHint.Email
    override val defaultValidators: List<FieldValidator>
        get() = listOf(required(), email())
}

KeyboardHint is an enum, not a Compose KeyboardType and not a UIKeyboardType. Each UI layer maps it to whatever it uses. That is the entire concession the core makes to the fact that a human will eventually type into this.

Five types ship built in: Email, Password, Name, Phone, Text. The first four pre-bake required(), and Email adds format checking on top. Text is empty on purpose. That one is yours to fill, and implementing FieldType yourself is how your own rules get the same treatment as the built-in ones.

The state is the half my UiState kept growing to accommodate, and here it comes with the field rather than being assembled around it:

val value: StateFlow<String>
val error: StateFlow<String?>
val isTouched: StateFlow<Boolean>
val isDirty: StateFlow<Boolean>
val isValidating: StateFlow<Boolean>

isTouched and isDirty are there so the UI can decide when an error is allowed to appear. An empty required field is invalid from the first frame, but saying so before the user has focused it once is hostile. The core reports the truth and lets the UI choose when to say it out loud. I was hand-rolling that decision per screen, or more often skipping it.


The compiler doing the checking

composure-compose is one function in two overloads, and its whole job is keeping a FormScope alive across recomposition and handing it a live coroutine scope.

The inline form, for screens where a form class would be ceremony:

val form = rememberFormState {
    field("email", Email)
    field("password", Password) { minLength(8) }
}

val email by form.get<Email>("email").value.collectAsState()
val isValid by form.isValid.collectAsState()

OutlinedTextField(
    value = email,
    onValueChange = { form.get<Email>("email").onChange(it) },
    label = { Text("Email") },
)

Button(onClick = form.handleSubmit { values -> viewModel.login(values) }, enabled = isValid) {
    Text("Sign in")
}

This one is convenience only. String keys are a runtime failure the same way my UiState properties were a silent one, so on anything real I use the typed variant:

class RegistrationForm(scope: FormScope) : FormController by scope {
    val email    = scope.field(Email) { async(checkEmailAvailability) }
    val password = scope.field(Password) { minLength(8); hasUppercase(); hasDigit() }
    val confirm  = scope.field(Password) { mustMatch(password) }
}

val form = rememberFormState { scope -> RegistrationForm(scope) }

Three lines, and they replace the ten properties and five handlers. FormController by scope is doing real work there. FormScope already implements FormController, which is the form-level surface: isValid, isDirty, isSubmitting, submitError, handleSubmit, reset. Delegating means RegistrationForm exposes its own typed fields and the whole form API from a single object, with no forwarding methods. form.email and form.isValid sit on the same receiver.

The part that answers my validateEmail/validatePassword mix-up is that the DSL is parameterised all the way down. FieldBuilder<T> carries the field type, and validators are extension functions on specific parameterisations:

fun FieldBuilder<Password>.hasUppercase(message: String = "...") = /* ... */
fun FieldBuilder<Phone>.minDigits(min: Int, message: String = "...") = /* ... */

field(Password) { hasUppercase() } compiles. field(Phone) { hasUppercase() } does not. The rule cannot be attached to the wrong field, because the wrong field does not have that function on it, and form.email is a FieldState<Email> rather than a String? on a data class that happens to be named after the right thing.


The field that watches another field

The confirm-password wiring is the clearest example of hand-built plumbing, because I wrote the same four lines in onPasswordChange in every project and got the guard wrong at least twice.

val password = scope.field(Password) { minLength(8) }
val confirm  = scope.field(Password) { mustMatch(password) }

mustMatch wraps dependsOn, which takes another FieldState and a lambda receiving both values. What matters is what FormScope does at registration: it records the relationship in a map from the dependency’s field name to the fields that depend on it, and consults it on every change.

// FormScope.onFieldChange
_dependents[state.fieldName]?.forEach { dependentName ->
    _entries.find { it.state.fieldName == dependentName }?.let { dep ->
        if (dep.state._isTouched.value) runSyncValidation(dep.state, dep)
    }
}

That isTouched guard is the bit people skip, including me. Without it, typing the first character of your password lights up a red “Passwords do not match” under a confirm field the user has never visited. The dependency also reads the other field’s value at validation time rather than capturing it, so there is no stale copy to keep in sync.

One deliberate omission: tracking is one level deep and there is no cycle detection. Two fields declaring each other will bounce validation back and forth. I have not hit it in a real form, and I would rather leave the limitation visible than put a graph resolver inside a library whose whole point is being small.


Async validation is a gate, not a check

Server-side email availability is where the hand-written version stopped being merely verbose and started being wrong. It looks like one feature. It is not.

Start with the obvious part: you should not ask the server about a malformed address. Composure gates that structurally.

val syncPassed = if (entry.trigger == ValidationTrigger.ON_CHANGE) {
    runSyncValidation(state, entry)
} else true

if (entry.asyncValidator != null) {
    val skipAsync = !syncPassed || (entry.isOptional && state._value.value.isBlank())
    if (!skipAsync) {
        scheduleAsyncValidation(state, entry)
    } else {
        _asyncJobs[state.fieldName]?.cancel()
        _asyncJobs.remove(state.fieldName)
        state._isValidating.value = false
    }
}

Sync validators run first, and the async one is only scheduled if they all passed. Typing p, pa, pas never reaches the network, because none of those pass the format check.

Then there is the state nobody plans for. The field is not valid and not invalid, it is waiting, which is the property my UiState would have grown next. isValidating covers it and feeds the form-level answer: isValid on a field requires no error and no check in flight, so a button bound to form.isValid goes disabled while a request is outstanding and comes back on its own. Nothing to remember at the call site.

And then debounce, which is really cancellation wearing a different name:

_asyncJobs[state.fieldName]?.cancel()
state._isValidating.value = true
updateFormState()

_asyncJobs[state.fieldName] = _coroutineScope.launch {
    delay(asyncDebounceMs)
    val result = asyncValidator.validate(state._value.value)
    state._isValidating.value = false
    state._error.value = when (result) {
        is ValidationResult.Valid -> null
        is ValidationResult.Invalid -> result.message
    }
    updateFormState()
}

One job per field, cancelled before a new one starts, with the 300ms delay inside the launched job rather than in front of it, so cancelling kills the pending wait and the request together and a fast typist produces exactly one call. asyncDebounceMs is a rememberFormState parameter when 300 is wrong for your API. Every one of those lines is something I would otherwise write per form, in a ViewModel, and probably simplify under deadline.


Crossing into Swift without leaking coroutines

A shared ViewModel already gets you to iOS, so this section is not about making the logic reachable. It’s about what the Swift side has to do to consume it, which in my old setup was a hand-written collector per property, wired into @Published fields by hand.

Kotlin’s Objective-C interop does not export generics usefully, does not hand SwiftUI anything it wants to bind to when it sees a StateFlow, and turns a Kotlin lambda returning Unit into a callback returning KotlinUnit. composure-ios absorbs that. FormField is a type-erased wrapper carrying its own CoroutineScope:

// composure-ios, iosMain
class FormField internal constructor(
    internal val state: FieldState<*>,
    private val scope: CoroutineScope,
) {
    fun watchValue(onChange: (String) -> Unit): ComposureSubscription =
        ComposureSubscription(scope.launch { state.value.collect { onChange(it) } })

    fun watchError(onChange: (String?) -> Unit): ComposureSubscription =
        ComposureSubscription(scope.launch { state.error.collect { onChange(it) } })

    fun update(value: String) = state.onChange(value)
    fun blur() = state.onBlur()
}

The generic parameter is gone, the flow is gone, and what crosses the boundary is a function taking a String. ComposureSubscription wraps the Job and exposes cancel(), which is what a Swift deinit needs. ComposureFormScope does the same at form level, bundling a FormScope with a SupervisorJob() + Dispatchers.Main scope so Swift never builds one.

The async bridge is the most interesting part, because Swift’s async/await and Kotlin’s suspend do not meet in the middle:

fun asyncValidator(check: (String, AsyncCheckCallback) -> Unit): AsyncFieldValidator =
    AsyncFieldValidator { value ->
        val deferred = CompletableDeferred<ValidationResult>()
        check(value, AsyncCheckCallback { errorMessage ->
            deferred.complete(
                if (errorMessage == null) ValidationResult.Valid
                else ValidationResult.Invalid(errorMessage)
            )
        })
        deferred.await()
    }

Swift gets a callback and fires it from inside a Task when its own await finishes. Kotlin suspends on the CompletableDeferred meanwhile, holding the coroutine open without blocking its thread. AsyncCheckCallback is a named fun interface rather than a bare lambda for one reason: the Swift signature reads callback.complete(errorMessage:) returning Void, instead of a closure returning KotlinUnit.

On top of that, one Swift file in the sample wraps FormField into an ObservableObject with @Published properties and the form into a base class:

class RegistrationForm: ComposureForm {
    lazy var email    = makeEmailField()
    lazy var password = makePasswordField()
    lazy var confirm  = makeConfirmField(matching: password)
}

Same form as the Kotlin RegistrationForm, same validators, and mustMatch re-validating the confirm field from Kotlin when the password changes. ComposureFormKit.swift lives in iosApp/ rather than in the library, because it is opinionated SwiftUI and I would rather you copy it and change it than depend on my styling decisions.


Gotchas

A form in composition memory does not survive a config change. rememberFormState remembers, and remember is not rememberSaveable. Rotate and the user retypes everything. FormScope is plain Kotlin with no Compose dependency, so if you are already putting forms in a ViewModel, keep doing exactly that:

class LoginViewModel : ViewModel() {
    private val scope = FormScope(viewModelScope)
    val form = LoginForm(scope)
}

For process death there is saveFieldData() and restoreFieldData(), round-tripping value, error, touched and dirty through a Map<String, List<String?>> suitable for a SavedStateHandle. They key on the name you gave the field, falling back to the generated field_0 identifier. Anonymous fields in a typed class therefore key positionally, which is fine right up until you reorder the declarations. Name your fields if you intend to restore them.

The coroutine scope is rebound on every recomposition, and that is not decoration. rememberCoroutineScope() can return a different scope, and a FormScope holding the original would schedule async validation into something already cancelled. rememberFormState calls bindCoroutineScope in a SideEffect, which is why the constructor scope is stored in a var.

handleSubmit returns a lambda, it does not run anything. It is built for onClick, so onClick = form.handleSubmit { ... } is right and onClick = { form.handleSubmit { ... } } builds a lambda and throws it away. Inside, it touches every field, validates, recalculates, and calls your block only if the form came out valid. Exceptions from your block land in submitError instead of crashing, and isSubmitting brackets the whole thing.

values in handleSubmit is keyed by internal field name. For the inline API those are your string keys; for anonymous typed fields they are field_0, field_1. With a typed form class I ignore the map and read form.email.value.value, which is type safe and does not care about declaration order.


The line I hold

Sharing validation logic across platforms is table stakes and I would not write an article about it. Use cases in commonMain behind a shared ViewModel is a good answer, it was my answer, and if that is where you are then nothing was broken.

What I stopped accepting is the amount of hand-written apparatus that answer leaves sitting around every form. A form has a shape: fields with rules, per-field interaction state, dependencies between fields, an async gate, a submit that validates before it runs. That shape is identical in every form anyone has ever written, and expressing it as a bespoke UiState and a handful of .copy() handlers means re-deriving it each time and hoping you remember isTouched. Declare the shape instead, let the type system hold the ends of it, and the compiler starts catching the class of mistake that used to depend on me concentrating.

Composure is on Maven Central as io.github.kmpbits:composure-core and io.github.kmpbits:composure-compose, with the iOS side available through Swift Package Manager from the same repo. It sits alongside the other libraries I maintain: Skeletal for skeleton loading in Compose Multiplatform, Netflow for networking, and KMP Splash for splash screens without opening Xcode.

Set the collar once, then let the tool click. 🏁


The library is available on GitHub.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Lights Out: Automatic Skeleton Loading in Compose Multiplatform