I've been building Android apps for years. And every single time I start a new form, login, sign-up, checkout, doesn't matter.
I write the same boilerplate.
Every. Single. Time.
The problem
It starts innocently enough.
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Then reality kicks in.
var email by remember { mutableStateOf("") }
var emailError by remember { mutableStateOf<String?>(null) }
var emailTouched by remember { mutableStateOf(false) }
var password by remember { mutableStateOf("") }
var passwordError by remember { mutableStateOf<String?>(null) }
var passwordTouched by remember { mutableStateOf(false) }
var passwordVisible by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val emailFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
And we haven't written a single line of UI yet.
Then validation.
fun validateEmail(): Boolean {
return if (email.isBlank()) {
emailError = "Email is required"
false
} else if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
emailError = "Please enter a valid email"
false
} else {
emailError = null
true
}
}
fun validatePassword(): Boolean {
return if (password.isBlank()) {
passwordError = "Password is required"
false
} else if (password.length < 8) {
passwordError = "Password must be at least 8 characters"
false
} else {
passwordError = null
true
}
}
Now add a username with async validation to check if it's taken.
Add a confirm password that must match.
Add a phone field that only appears if the user selects "phone" as their contact method.
Add a terms checkbox that must be checked before submit.
A 6-field sign-up form easily hits 400 lines before you write a single OutlinedTextField.
Every team reinvents this differently. It is completely untestable without mocking half your ViewModel. And when requirements change, a new field, a new validation rule, you touch five different places.
There has to be a better way.
The solution
What if the form was just a data class?
@FormSchema
data class SignUpForm(
@Field(label = "Email", hint = "Your email address")
@Email
@NotBlank
val email: String = "",
@Field(label = "Password", hint = "At least 8 characters")
@NotBlank
@MinLength(8)
val password: String = "",
@Field(label = "Confirm Password")
@NotBlank
@MatchField(targetField = "password", message = "Not matching")
val confirmPassword: String = "",
@Field(label = "Username")
@NotBlank
@AsyncValidation(UniqueUsernameValidator::class)
val username: String = "",
)
That's the entire form definition.
No manual state, no validation functions, no focus wiring.
This is the idea behind Formidable: a KSP-powered form engine for Jetpack Compose. You annotate a data class, and at compile time KSP generates a full SignUpFormController for you.
What gets generated
For every field in your schema, the controller gives you:
- A StateFlow<FieldState<T>> value, errors, touched state, visibility, validation status
- updateX() / touchX() functions
- validateAllSync() for submit-time validation
- isValid a derived flow that's true only when all fields pass
Zero reflection.
Zero runtime overhead.
Everything resolved at compile time.
Using it in Compose
Formidable renders OutlinedTextField for you, no need to declare UI element, simply use one of the fields from Formidable and it will auto-wires focus, keyboard navigation, and validation/submission.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val emailState by viewModel.controller.email.collectAsState()
val passwordState by viewModel.controller.password.collectAsState()
val isValid by viewModel.controller.isValid.collectAsState()
Formidable {
StringField(
state = emailState,
onValueChange = { viewModel.controller.updateEmail(it) },
onFocusLost = { viewModel.controller.touchEmail() },
)
StringField(
state = passwordState,
onValueChange = { viewModel.controller.updatePassword(it) },
onFocusLost = { viewModel.controller.touchPassword() },
config = {
visualTransformation = PasswordVisualTransformation()
keyboardType = KeyboardType.Password
},
)
Button(onClick = { /* submit */ }, enabled = isValid) {
Text("Login")
}
}
}
Async validation
Provide your own class extending AsyncFieldValidator
For example:
UniqueUsernameValidator : AsyncFieldValidator<String> {
override suspend fun validate(value: String): ValidationResult {
delay(500) // Simulate network call
val taken = listOf("admin", "user", "test", "root")
return if (value.lowercase() in taken) {
ValidationResult.Invalid(listOf("Username '$value' is already taken"))
} else {
ValidationResult.Valid
}
}
}
Cross-field rules
Fields that react to other fields. No manual derivedStateOf, no observer chains.
RequiredIf, VisibleWhen, MatchField
What I learned building it
The problem with form state in Compose isn't Compose. Compose is great. The problem is that there's no established pattern for treating a form as a first-class entity rather than a bag of loosely related state variables.
Annotations and code generation aren't magic, they're just moving the boilerplate to a place where you write it once and forget it.
The moment I stopped thinking about forms as "a bunch of fields" and started thinking about them as "a typed schema with well-defined behavior," the architecture became obvious.
Try it:
Github: https://github.com/WassimBeltaief/formidable
Github pages: https://wassimbeltaief.github.io/Formidable/


Top comments (0)