
Reactive, type-safe form validation with a declarative DSL: automatic per-field validation, name-based field identities, composable reusable inputs, built-in validators and easy custom extensions.
Reactive, type-safe form validation for Compose Multiplatform (Android & iOS)
Build declarative, cross-platform forms that validate themselves as users type.
Field<T> enforces consistent types.Validator<T> easily.repositories {
mavenCentral()
}
dependencies {
implementation("com.quantipixels:ikokuko:0.1.0")
}FormState manages all field values, validation errors, and visibility flags for a form.
It’s the single source of truth for the form’s current state.
Optionally pass shouldShowErrors when creating the state to control its initial error visibility behavior.
// Default: errors hidden until submit or manual toggle
val formState = remember { FormState() }
// Errors become visible after submit or as fields change (dirty)
val formState = remember { FormState(shouldShowErrors = true) }Controls when validation errors are globally visible.
| Value | Behaviour | Typical Use Case |
|---|---|---|
false (default) |
Validation runs continuously, but errors are hidden until submit() or manual toggle. | Most common — errors appear only after first submit. |
true |
Errors become visible once a field value changes (becomes dirty) or after submit. | Used when you want validation messages to show immediately upon interaction. |
You can toggle this flag at any time from either the FormState or inside the FormScope.
// From FormState
formState.shouldShowErrors = true // Show all validation errors
formState.shouldShowErrors = false // Hide errors again
// From FormScope
Form(onSubmit = {}) {
// ...
shouldShowErrors = true // Show all validation errors
shouldShowErrors = false // Hide errors again
}The form can be reset from either the FormState or inside the FormScope.
// From FormState
formState.reset()
// From FormScope
Form(onSubmit = {}) {
// ...
Button(onClick = ::reset) { Text("Reset Form") }
}You can define a Field using either typed constructors or generic syntax, depending on your use case and desired type safety.
val EmailField = Field.Text("email")
val RememberMeField = Field.Boolean("remember_me")
val RangeField = Field.Range("price_range") // ClosedFloatingPointRange<Float>Field directly with its type parameter:val NameField = Field<String>("name")
val CustomField = Field<MyCustomData>("custom")You can define Field objects as
// top-level (or global)
val EmailField = Field.Text("email")
@Composable
fun DemoForm() {
// local — recreated on every recomposition (fine for stateless forms)
val emailField = Field.Text("email")
// composable-scoped — stable across recompositions
val emailField = remember { Field.Text("email") }
}Field instances are identified by their name, not by object identity.Field objects are cheap to construct; there’s no need to remember them unless you prefer stable references.| Case | Behaviour |
|---|---|
| Same name, same type | Fields share the same value in the FormState. Updating one updates them all. |
| Same name, different type | Causes a crash when FormScope tries to cast the stored value back to the wrong type. |
| Different names | Fields maintain independent values and validation states. |
Always ensure that all form fields have unique names within a single
FormScope.
You can connect fields to your FormState and enable validation in two ways:
Form(onSubmit={ println("Email: ${EmailField.value}") }) {
ValidationEffect(
field = EmailField,
default = "",
validators = listOf(
RequiredValidator("Email required"),
EmailValidator("Invalid email")
)
)
OutlinedTextField(
value = EmailField.value,
isError = !EmailField.isValid,
label = { Text("Email") },
supportingText = EmailField.error?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
onValueChange = { EmailField.value = it }
)
}FormField(
field = EmailField,
default = "",
validators = listOf(
RequiredValidator("Email required"),
EmailValidator("Invalid email")
)
) {
OutlinedTextField(
value = EmailField.value,
isError = !EmailField.isValid,
label = { Text("Email") },
supportingText = EmailField.error?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
onValueChange = { EmailField.value = it }
)
}Each Field exposes an error property that represents its current validation error message, and it can be set or cleared manually at any time.
var Field<*>.error: String?Normally, this value is updated automatically by ValidationEffect whenever validators fail, but you can override it manually for advanced use cases such as:
// Inside a FormScope
// Assign error message
if (EmailField.value.endsWith("@test.com")) {
EmailField.error = "Test domains are not allowed"
}
// Clear the error message
EmailField.error = nullìkọkúkọ’s FormScope lets you build reusable composable form components that automatically handle value binding, validation, and error display. This makes it easy to define input fields once and reuse them across different forms.
You can create a reusable text input field as an extension on FormScope:
@Composable
fun FormScope.TextInput(
field: Field<String>,
modifier: Modifier = Modifier,
initialValue: String = "",
label: String = "",
placeholder: String = "",
validators: List<Validator<String>> = emptyList()
) {
FormField(field, initialValue, validators) {
Column(modifier = modifier) {
OutlinedTextField(
value = field.value,
isError = !field.isValid,
label = { Text(label) },
placeholder = {
Text(
placeholder,
color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
)
},
supportingText = field.error?.let { { Text(it) } },
onValueChange = { field.value = it },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
}
}
}ValidationEffect attaches validators and ensures the field’s value and errors stay reactive.field.value binds the text input to the form state.field.error provides the active error message when visible.field.isValid drives the error styling (isError = !field.isValid).All form logic is encapsulated inside the FormScope, so the field automatically integrates with submit(), reset(), and global validation visibility.
Compose your complete form by combining your defined fields, inputs, and validators inside a Form. The Form automatically manages field registration, validation, and submission through a shared FormState. It also supports cross-field validation, allowing validators to depend on the values of other fields (e.g. password confirmation, date ranges, matching inputs).
💡 This example builds on the reusable
TextInputcomponent described in the previous section — each input is already wired to its corresponding Field and validation logic.
val EmailField = Field.Text("email")
val PasswordField = Field.Text("password")
val ConfirmPasswordField = Field.Text("password")
@Composable
fun SignUpForm() {
val formState = remember { FormState() }
Form(state = formState, onSubmit = {
println("Email: ${EmailField.value}")
println("Password: ${PasswordField.value}")
println("Password Confirmation: ${ConfirmPasswordField.value}")
}) {
Column {
TextInput(
field = EmailField,
label = "Email",
validators = listOf(
RequiredValidator("Email required"),
EmailValidator("Invalid email")
)
)
TextInput(
field = PasswordField,
label = "Password",
validators = listOf(
RequiredValidator("Password required"),
MinLengthValidator("At least 8 characters", 8)
)
)
// Cross-field validation
// The EqualsValidator references PasswordField.value to ensure both match.
TextInput(
field = ConfirmPasswordField,
label = "Password Confirmation",
validators = listOf(
RequiredValidator("password confirmation is required"),
EqualsValidator("passwords must match") { PasswordField.value }
)
)
Button(onClick = ::submit, enabled = isValid) {
Text("Sign In")
}
}
}
}TextInput reusable component defined in the previous section.FormState tracks and validates all registered fields automatically.onSubmit callback executes only when all validations pass.isValid property enables you to toggle UI elements like buttons based on current form validity.| Validator | Description |
|---|---|
RequiredValidator |
Must not be blank |
MinLengthValidator |
Minimum characters |
MaxLengthValidator |
Maximum characters |
LengthValidator |
Exact length |
| Validator | Description |
|---|---|
MinValidator |
≥ min |
MaxValidator |
≤ max |
RangeValidator |
Between min and max |
| Validator | Description |
|---|---|
MatchPatternValidator |
Entire string matches regex |
ContainsPatternValidator |
Regex occurs anywhere |
EmailValidator |
Standard email format |
PhoneNumberValidator |
E.164 phone format |
| Validator | Description |
|---|---|
InValidator |
Value must be in the allowed set |
NotInValidator |
Value must not be in the disallowed set |
| Validator | Description |
|---|---|
EqualsValidator |
Must equal expected value |
NotEqualsValidator |
Must differ from unwanted value |
| Validator | Description |
|---|---|
NonEmptySelectionValidator |
Selection not empty |
MinSelectionValidator |
At least N items |
MaxSelectionValidator |
At most N items |
ExactSelectionValidator |
Exactly N items |
SelectionRangeValidator |
Between min and max items |
SelectionInValidator |
Ensures all selected values are within the allowed options |
Implement the Validator<T> interface:
class StartsWithValidator(
override val errorMessage: String,
private val prefix: String
) : Validator<String> {
override fun validate(value: String) = value.startsWith(prefix)
}Use it normally:
ValidationEffect(
field = UsernameField,
default = "",
validators = listOf(StartsWithValidator("Must start with @", "@"))
)See Ikokuko — the reactive, type-safe form validation library for Compose Multiplatform (Android & iOS) — in action:
https://github.com/user-attachments/assets/d83ed2e5-5cc0-4034-9cb3-98d355177db5
This short video showcases real-time validation and error handling using Ikokuko in a Compose Multiplatform sample app.
Copyright 2025 Quanti Pixels
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Reactive, type-safe form validation for Compose Multiplatform (Android & iOS)
Build declarative, cross-platform forms that validate themselves as users type.
Field<T> enforces consistent types.Validator<T> easily.repositories {
mavenCentral()
}
dependencies {
implementation("com.quantipixels:ikokuko:0.1.0")
}FormState manages all field values, validation errors, and visibility flags for a form.
It’s the single source of truth for the form’s current state.
Optionally pass shouldShowErrors when creating the state to control its initial error visibility behavior.
// Default: errors hidden until submit or manual toggle
val formState = remember { FormState() }
// Errors become visible after submit or as fields change (dirty)
val formState = remember { FormState(shouldShowErrors = true) }Controls when validation errors are globally visible.
| Value | Behaviour | Typical Use Case |
|---|---|---|
false (default) |
Validation runs continuously, but errors are hidden until submit() or manual toggle. | Most common — errors appear only after first submit. |
true |
Errors become visible once a field value changes (becomes dirty) or after submit. | Used when you want validation messages to show immediately upon interaction. |
You can toggle this flag at any time from either the FormState or inside the FormScope.
// From FormState
formState.shouldShowErrors = true // Show all validation errors
formState.shouldShowErrors = false // Hide errors again
// From FormScope
Form(onSubmit = {}) {
// ...
shouldShowErrors = true // Show all validation errors
shouldShowErrors = false // Hide errors again
}The form can be reset from either the FormState or inside the FormScope.
// From FormState
formState.reset()
// From FormScope
Form(onSubmit = {}) {
// ...
Button(onClick = ::reset) { Text("Reset Form") }
}You can define a Field using either typed constructors or generic syntax, depending on your use case and desired type safety.
val EmailField = Field.Text("email")
val RememberMeField = Field.Boolean("remember_me")
val RangeField = Field.Range("price_range") // ClosedFloatingPointRange<Float>Field directly with its type parameter:val NameField = Field<String>("name")
val CustomField = Field<MyCustomData>("custom")You can define Field objects as
// top-level (or global)
val EmailField = Field.Text("email")
@Composable
fun DemoForm() {
// local — recreated on every recomposition (fine for stateless forms)
val emailField = Field.Text("email")
// composable-scoped — stable across recompositions
val emailField = remember { Field.Text("email") }
}Field instances are identified by their name, not by object identity.Field objects are cheap to construct; there’s no need to remember them unless you prefer stable references.| Case | Behaviour |
|---|---|
| Same name, same type | Fields share the same value in the FormState. Updating one updates them all. |
| Same name, different type | Causes a crash when FormScope tries to cast the stored value back to the wrong type. |
| Different names | Fields maintain independent values and validation states. |
Always ensure that all form fields have unique names within a single
FormScope.
You can connect fields to your FormState and enable validation in two ways:
Form(onSubmit={ println("Email: ${EmailField.value}") }) {
ValidationEffect(
field = EmailField,
default = "",
validators = listOf(
RequiredValidator("Email required"),
EmailValidator("Invalid email")
)
)
OutlinedTextField(
value = EmailField.value,
isError = !EmailField.isValid,
label = { Text("Email") },
supportingText = EmailField.error?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
onValueChange = { EmailField.value = it }
)
}FormField(
field = EmailField,
default = "",
validators = listOf(
RequiredValidator("Email required"),
EmailValidator("Invalid email")
)
) {
OutlinedTextField(
value = EmailField.value,
isError = !EmailField.isValid,
label = { Text("Email") },
supportingText = EmailField.error?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
onValueChange = { EmailField.value = it }
)
}Each Field exposes an error property that represents its current validation error message, and it can be set or cleared manually at any time.
var Field<*>.error: String?Normally, this value is updated automatically by ValidationEffect whenever validators fail, but you can override it manually for advanced use cases such as:
// Inside a FormScope
// Assign error message
if (EmailField.value.endsWith("@test.com")) {
EmailField.error = "Test domains are not allowed"
}
// Clear the error message
EmailField.error = nullìkọkúkọ’s FormScope lets you build reusable composable form components that automatically handle value binding, validation, and error display. This makes it easy to define input fields once and reuse them across different forms.
You can create a reusable text input field as an extension on FormScope:
@Composable
fun FormScope.TextInput(
field: Field<String>,
modifier: Modifier = Modifier,
initialValue: String = "",
label: String = "",
placeholder: String = "",
validators: List<Validator<String>> = emptyList()
) {
FormField(field, initialValue, validators) {
Column(modifier = modifier) {
OutlinedTextField(
value = field.value,
isError = !field.isValid,
label = { Text(label) },
placeholder = {
Text(
placeholder,
color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
)
},
supportingText = field.error?.let { { Text(it) } },
onValueChange = { field.value = it },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
}
}
}ValidationEffect attaches validators and ensures the field’s value and errors stay reactive.field.value binds the text input to the form state.field.error provides the active error message when visible.field.isValid drives the error styling (isError = !field.isValid).All form logic is encapsulated inside the FormScope, so the field automatically integrates with submit(), reset(), and global validation visibility.
Compose your complete form by combining your defined fields, inputs, and validators inside a Form. The Form automatically manages field registration, validation, and submission through a shared FormState. It also supports cross-field validation, allowing validators to depend on the values of other fields (e.g. password confirmation, date ranges, matching inputs).
💡 This example builds on the reusable
TextInputcomponent described in the previous section — each input is already wired to its corresponding Field and validation logic.
val EmailField = Field.Text("email")
val PasswordField = Field.Text("password")
val ConfirmPasswordField = Field.Text("password")
@Composable
fun SignUpForm() {
val formState = remember { FormState() }
Form(state = formState, onSubmit = {
println("Email: ${EmailField.value}")
println("Password: ${PasswordField.value}")
println("Password Confirmation: ${ConfirmPasswordField.value}")
}) {
Column {
TextInput(
field = EmailField,
label = "Email",
validators = listOf(
RequiredValidator("Email required"),
EmailValidator("Invalid email")
)
)
TextInput(
field = PasswordField,
label = "Password",
validators = listOf(
RequiredValidator("Password required"),
MinLengthValidator("At least 8 characters", 8)
)
)
// Cross-field validation
// The EqualsValidator references PasswordField.value to ensure both match.
TextInput(
field = ConfirmPasswordField,
label = "Password Confirmation",
validators = listOf(
RequiredValidator("password confirmation is required"),
EqualsValidator("passwords must match") { PasswordField.value }
)
)
Button(onClick = ::submit, enabled = isValid) {
Text("Sign In")
}
}
}
}TextInput reusable component defined in the previous section.FormState tracks and validates all registered fields automatically.onSubmit callback executes only when all validations pass.isValid property enables you to toggle UI elements like buttons based on current form validity.| Validator | Description |
|---|---|
RequiredValidator |
Must not be blank |
MinLengthValidator |
Minimum characters |
MaxLengthValidator |
Maximum characters |
LengthValidator |
Exact length |
| Validator | Description |
|---|---|
MinValidator |
≥ min |
MaxValidator |
≤ max |
RangeValidator |
Between min and max |
| Validator | Description |
|---|---|
MatchPatternValidator |
Entire string matches regex |
ContainsPatternValidator |
Regex occurs anywhere |
EmailValidator |
Standard email format |
PhoneNumberValidator |
E.164 phone format |
| Validator | Description |
|---|---|
InValidator |
Value must be in the allowed set |
NotInValidator |
Value must not be in the disallowed set |
| Validator | Description |
|---|---|
EqualsValidator |
Must equal expected value |
NotEqualsValidator |
Must differ from unwanted value |
| Validator | Description |
|---|---|
NonEmptySelectionValidator |
Selection not empty |
MinSelectionValidator |
At least N items |
MaxSelectionValidator |
At most N items |
ExactSelectionValidator |
Exactly N items |
SelectionRangeValidator |
Between min and max items |
SelectionInValidator |
Ensures all selected values are within the allowed options |
Implement the Validator<T> interface:
class StartsWithValidator(
override val errorMessage: String,
private val prefix: String
) : Validator<String> {
override fun validate(value: String) = value.startsWith(prefix)
}Use it normally:
ValidationEffect(
field = UsernameField,
default = "",
validators = listOf(StartsWithValidator("Must start with @", "@"))
)See Ikokuko — the reactive, type-safe form validation library for Compose Multiplatform (Android & iOS) — in action:
https://github.com/user-attachments/assets/d83ed2e5-5cc0-4034-9cb3-98d355177db5
This short video showcases real-time validation and error handling using Ikokuko in a Compose Multiplatform sample app.
Copyright 2025 Quanti Pixels
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.