SkillAgentSearch skills...

Ikokuko

Reactive, type-safe form validation for Compose Multiplatform (Android & iOS)

Install / Use

/learn @quantipixels/Ikokuko
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Ikokuko Banner

ìkọkúkọ

Maven Central License Kotlin Android iOS

Reactive, type-safe form validation for Compose Multiplatform (Android & iOS)
Build declarative, cross-platform forms that validate themselves as users type.


Features

  • Lightweight – no reflection or annotation processors.
  • Compose-first – integrates naturally with Compose Multiplatform UIs.
  • Reactive validation – runs automatically when field values change.
  • Type-safe fieldsField<T> enforces consistent types.
  • Composable DSL – define forms and validators declaratively.
  • Platform-agnostic – works on Android, Desktop, iOS, JS, and Wasm.
  • Built-in validators – text, numeric, pattern, equality, selection.
  • Extendable – implement your own Validator<T> easily.

Getting Started

1. Add dependency

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.quantipixels:ikokuko:0.1.0")
}

2. FormState

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) }

shouldShowErrors

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
}

Resetting the form

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") }
}

3. Defining Fields

You can define a Field using either typed constructors or generic syntax, depending on your use case and desired type safety.

  • Typed constructors (recommended for readability) — ìkọkúkọ provides convenience factory functions for the most common field types:
val EmailField = Field.Text("email")
val RememberMeField = Field.Boolean("remember_me")
val RangeField = Field.Range("price_range") // ClosedFloatingPointRange<Float>
  • Generic field syntax (for custom or advanced cases) — You can also define a 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),
  • local, or
  • composable-scoped values — they’re lightweight and can be freely recreated.
// 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") }
}

How fields work

  • Field instances are identified by their name, not by object identity.
  • You can safely recreate them on each composition — their state in the form will persist as long as the name stays the same.
  • Field objects are cheap to construct; there’s no need to remember them unless you prefer stable references.

Name-based behavior

|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.|

Recommended

Always ensure that all form fields have unique names within a single FormScope.


4. Add Validation and Connect Fields to the FormState

You can connect fields to your FormState and enable validation in two ways:

  • Manual setup — call ValidationEffect directly to register and validate a field.
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 }
    )
}
  • Convenience setup — use FormField, which automatically registers the field and runs validation on value changes.
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 }
    )
}

5. Overriding Errors Manually

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:

  • Server-side or asynchronous validation (e.g. username already taken).
  • Custom inline validation not covered by existing Validator classes.
  • Resetting or clearing errors programmatically.

Example: Manual error assignment

// 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

6. Creating Reusable Form Components

ì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.

Example: TextInput

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))
        }
    }
}

How it works

  • 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.


7. Build a form

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 th

Related Skills

View on GitHub
GitHub Stars22
CategoryDevelopment
Updated1mo ago
Forks0

Languages

Kotlin

Security Score

95/100

Audited on Feb 5, 2026

No findings