DEV Community

Cover image for Why is form state management in Jetpack Compose still this painful?
Wassim Beltaief
Wassim Beltaief

Posted on

Why is form state management in Jetpack Compose still this painful?

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("") }
Enter fullscreen mode Exit fullscreen mode

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() }  
Enter fullscreen mode Exit fullscreen mode

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                                                                                                                                                                                                   
      }                                                                                                                                                                                                          
}           
Enter fullscreen mode Exit fullscreen mode

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 = "",                                                                                                                                                                                 
)        
Enter fullscreen mode Exit fullscreen mode

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Cross-field rules
Fields that react to other fields. No manual derivedStateOf, no observer chains.

RequiredIf, VisibleWhen, MatchField
Enter fullscreen mode Exit fullscreen mode

What I learned building it

Typing in the form with validation and error handling

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/

Formidable Banner

Top comments (0)