KSP-powered · Compose Multiplatform · No reflection

Headless forms for
Jetpack Compose

Annotate a data class, get a type-safe controller. Sync & async validation, cross-field rules, conditional visibility — no runtime reflection.

Get started Live demo →

KSP code generation

Zero runtime reflection. A typed controller is generated at compile time from your annotated data class.

🔒

Type-safe validation

Sync and async validators, cross-field rules, conditional visibility — all checked at compile time.

🎨

Headless by design

No UI opinions. You own every pixel. Wire any composable to a FieldScope and you're done.

🌍

Multiplatform

Android, iOS, and WASM via Compose Multiplatform. One schema, every target.

Installation

Add the dependencies and the KSP plugin to your module's build.gradle.kts.

plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("io.github.wassimbeltaief:formidable-core:2.0.0")
    implementation("io.github.wassimbeltaief:formidable-compose:2.0.0")
    ksp("io.github.wassimbeltaief:formidable-ksp:2.0.0")
}

Quick start

Two steps: annotate a data class, wire the fields in Compose.

1

Define your schema

Annotate a plain data class with @FormSchema. KSP generates a LoginFormController for you at build time.

@FormSchema
data class LoginForm(
    @Field(label = "Email", hint = "you@example.com")
    @Email("Please enter a valid email")
    val email: String = "",

    @Field(label = "Password", hint = "At least 8 characters")
    @MinLength(8, "Password must be at least 8 characters")
    val password: String = "",
)
2

Wire it in Compose

Call StringField, BooleanField, EnumField etc. inside Formidable {}. Auto-rendering handles the UI — override with config = { } when you need it, or pass a trailing lambda for full control.

@Composable
fun LoginScreen() {
    val controller = remember { LoginFormController() }
    val emailState by controller.email.collectAsState()
    val passwordState by controller.password.collectAsState()
    val isValid by controller.isValid.collectAsState()

    Formidable {
        StringField(
            state = emailState,
            onValueChange = { controller.updateEmail(it) },
            onFocusLost = { controller.touchEmail() },
        )
        StringField(
            state = passwordState,
            onValueChange = { controller.updatePassword(it) },
            onFocusLost = { controller.touchPassword() },
            config = {
                keyboardType = KeyboardType.Password
                visualTransformation = PasswordVisualTransformation()
            },
        )
        Button(onClick = { /* submit */ }, enabled = isValid) {
            Text("Login")
        }
    }
}

Three rendering modes

Every field supports three call forms. Start with auto-render, reach for config overrides when needed, drop to headless for full control.

Mode 1

Auto-render

Renders a Material 3 OutlinedTextField (or the appropriate widget for the field type). Wires label, hint, error message, and loading spinner — all from the field state. Zero boilerplate.

StringField(
    state = emailState,
    onValueChange = { controller.updateEmail(it) },
    onFocusLost = { controller.touchEmail() },
)

BooleanField(
    state = acceptTermsState,
    onCheckedChange = { controller.updateAcceptTerms(it) },
    onFocusLost = { controller.touchAcceptTerms() },
)
Mode 2

Config override

Still auto-rendered, but you override specific slots via a named config = { } argument. The config must be a named argument — trailing-lambda syntax is reserved for the headless API. Available slots: style, keyboardType, visualTransformation, supportingText, trailingIcon, leadingIcon, singleLine.

// Password field — override keyboard type and visual transformation
StringField(
    state = passwordState,
    onValueChange = { controller.updatePassword(it) },
    onFocusLost = { controller.touchPassword() },
    config = {
        visualTransformation = PasswordVisualTransformation()
        keyboardType = KeyboardType.Password
    },
)

// Enum field rendered as radio group instead of dropdown
EnumField(
    state = contactMethodState,
    options = ContactMethod.entries,
    onSelect = { controller.updateContactMethod(it) },
    config = { style = FieldStyle.Picker.RadioGroup },
)

// Switch row instead of checkbox row
BooleanField(
    state = notificationsState,
    onCheckedChange = { controller.updateNotifications(it) },
    onFocusLost = { controller.touchNotifications() },
    config = { style = FieldStyle.Toggle.SwitchRow },
)
Mode 3

Headless (trailing lambda)

The trailing lambda receives a typed FieldScope. You own the UI entirely — no Material 3 dependency used. The scope provides value, modifier, keyboardOptions, keyboardActions, showError, errorMessage, and more — see the FieldScope table below.

StringField(
    state = emailState,
    onValueChange = { controller.updateEmail(it) },
    onFocusLost = { controller.touchEmail() },
) {
    OutlinedTextField(
        value = value,
        onValueChange = onValueChange,
        modifier = modifier.fillMaxWidth(),
        label = { Text(label) },
        isError = showError,
        supportingText = if (showError) { { Text(errorMessage ?: "") } } else null,
        keyboardOptions = keyboardOptions,
        keyboardActions = keyboardActions,
    )
}

FieldStyle reference

Pass style = FieldStyle.* inside a config = { } block to change the rendered widget.

Field type Default style Other styles
StringField
NullableStringField
IntField
FieldStyle.Text.Outlined FieldStyle.Text.Filled
BooleanField FieldStyle.Toggle.CheckboxRow Toggle.Checkbox
Toggle.Switch
Toggle.SwitchRow
EnumField FieldStyle.Picker.Dropdown Picker.RadioGroup
Picker.SegmentedButton

FieldScope

When using the headless trailing-lambda API, every field block receives a typed scope with these properties.

Property Type Description
value T Current field value
onValueChange (T) → Unit Called on every keystroke
showError Boolean True when the field is touched and has errors
errorMessage String? First validation error, or null
isValidating Boolean True while an async validator is running
label String Label from @Field
hint String Placeholder hint from @Field
modifier Modifier Pre-wired with focus requester and scroll anchor
keyboardOptions KeyboardOptions Auto-configured ImeAction (Next / Done)
keyboardActions KeyboardActions Auto-focuses next field on Next action

Annotations

All annotations live in formidable-core.

Annotation Target Description
@FormSchema class Triggers controller code generation
@Field(label, hint) property Attaches label and placeholder hint to a field
@NotBlank String Must not be blank
@MinLength(n) String Minimum character count
@MaxLength(n) String Maximum character count
@Email String Valid email format
@Pattern(regex) String Matches a regular expression
@IntRange(min, max) Int Value within the given range
@MustBeTrue Boolean Must be true — for checkbox consent
@MatchField(field) String Must equal the value of another field
@RequiredIf(field, value) String? Required only when another field equals a value
@AsyncValidation(KClass) any Runs a suspending AsyncFieldValidator
@VisibleWhen(field, KClass) any Conditionally shows or hides a field

Async validation

Implement AsyncFieldValidator<T> and attach it with @AsyncValidation. The controller debounces automatically and exposes isValidating for a loading indicator.

class UniqueUsernameValidator : AsyncFieldValidator<String> {
    override suspend fun validate(value: String): ValidationResult {
        val taken = api.checkUsername(value)
        return if (taken)
            ValidationResult.Invalid(listOf("Username '$value' is already taken"))
        else
            ValidationResult.Valid
    }
}

@FormSchema
data class SignUpForm(
    @Field(label = "Username")
    @NotBlank("Username is required")
    @AsyncValidation(UniqueUsernameValidator::class)
    val username: String = "",
)

Use config = { } to override the supporting text and trailing icon while keeping auto-rendering. usernameState is read from the outer composable's collectAsState() — the config lambda stores composable values but is not itself composable.

val usernameState by controller.username.collectAsState()

StringField(
    state = usernameState,
    onValueChange = { controller.updateUsername(it) },
    onFocusLost = { controller.touchUsername() },
    config = {
        supportingText = when {
            usernameState.isValidating -> { { Text("Checking availability…") } }
            usernameState.showError    -> { { Text(usernameState.errorMessage ?: "") } }
            else -> null
        }
        trailingIcon = if (usernameState.isValidating) {
            { CircularProgressIndicator(Modifier.size(20.dp)) }
        } else null
    },
)

Cross-field validation

Use @MatchField to require a field to match another — the classic password confirmation pattern. The generated controller re-validates the dependent field automatically whenever the source changes.

@FormSchema
data class SignUpForm(
    @Field(label = "Password")
    @MinLength(8)
    val password: String = "",

    @Field(label = "Confirm password")
    @MatchField("password", "Passwords do not match")
    val confirmPassword: String = "",
)

See it live

A full sign-up form running live in your browser via Compose Multiplatform WASM. Async validation, cross-field rules, conditional visibility — all in one demo.

Open live demo →