Back to Blog

State & Input in Kotlin Desktop: Compose Multiplatform | Lesson 03

Sandy LaneSandy Lane

Video: State & Input in Kotlin Desktop: Compose Multiplatform | Lesson 03 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

State and Input in Compose Desktop: A Settings Form

One immutable data class FormState. Hoisted state. Pure validation functions. The Compose pattern for any form, from one field to one hundred.

Lesson 2 was static — a Profile Card with no interactivity. Lesson 3 is where the UI starts taking input. We build a settings form with text fields (with email validation), checkboxes, a switch, and radio buttons. Five fields, all bound to a single immutable state object, all updates flowing through a single (FormState) -> Unit callback.

The pattern is state hoisting — Composables don't own their state; the parent does. Children render the current state and report changes via callbacks. Once you've internalised this pattern, you've internalised most of how Compose UIs are structured.

What we are building

A centred card with:

  • Two text inputs (Name, Email) — Email shows an error if it doesn't contain @ and ..
  • A checkbox (Enable notifications).
  • A switch (Dark mode).
  • A radio group (English, Spanish, Japanese).
  • A Save button that prints the state to stdout.

Project layout

src/main/kotlin/
├── Main.kt              # @Composable App + window
├── FormState.kt         # the data class + validateEmail
├── SettingsForm.kt      # the card composable
├── FormFields.kt        # ProfileFields, PreferenceControls, LanguageSelector
├── SectionLabel.kt      # the small "PROFILE" / "PREFERENCES" header
└── Colors.kt            # the palette

FormState.kt: the data shape

data class FormState(
  val name: String = "",
  val email: String = "",
  val notifications: Boolean = true,
  val darkMode: Boolean = true,
  val language: String = "English"
)

fun validateEmail(email: String): String? {
  if (email.isBlank()) return null
  if (!email.contains("@") || !email.contains(".")) {
    return "Invalid email address"
  }
  return null
}

Two pieces.

The data class holds all five form fields with default values. val not var — the data class is immutable. To "change" it, you call state.copy(name = newName) which produces a new instance with the named field replaced.

validateEmail returns null for "no error" or a message string for "show this error." Pure function — no side effects, no Compose dependency, easily testable.

State hoisting in Main.kt

@Composable
fun App() {
  val formState = remember { mutableStateOf(FormState()) }

  MaterialTheme(colorScheme = darkColorScheme()) {
    Surface(modifier = Modifier.fillMaxSize(), color = DarkBackground) {
      Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        SettingsForm(
          state = formState.value,
          onStateChange = { formState.value = it },
          onSave = { println("Saved: ${formState.value}") }
        )
      }
    }
  }
}

App() owns the formState via remember { mutableStateOf(FormState()) } — same pattern as Lesson 1's counter, applied to a richer state type.

SettingsForm is stateless. It takes:

  • state: FormState — the current snapshot.
  • onStateChange: (FormState) -> Unit — a callback to report a new snapshot.
  • onSave: () -> Unit — a callback for the save button.

This is hoisting. The form doesn't own state; it asks the parent for state and reports updates. The parent can persist, log, or transform the state any way it likes — the form doesn't care.

SettingsForm.kt: the container

@Composable
fun SettingsForm(
  state: FormState,
  onStateChange: (FormState) -> Unit,
  onSave: () -> Unit
) {
  Card(
    modifier = Modifier.width(420.dp).padding(16.dp),
    shape = RoundedCornerShape(24.dp),
    colors = CardDefaults.cardColors(containerColor = DarkSurface),
    elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
  ) {
    Column(modifier = Modifier.fillMaxWidth().padding(32.dp)) {
      Text("Settings", fontSize = 28.sp, color = Color.White)
      Spacer(modifier = Modifier.height(16.dp))

      ProfileFields(state, onStateChange)
      PreferenceControls(state, onStateChange)
      LanguageSelector(state, onStateChange)

      Spacer(modifier = Modifier.height(24.dp))
      Button(
        onClick = onSave,
        modifier = Modifier.fillMaxWidth().height(48.dp),
        shape = RoundedCornerShape(12.dp),
        colors = ButtonDefaults.buttonColors(containerColor = Purple40)
      ) {
        Text("Save", fontSize = 16.sp)
      }
    }
  }
}

Three sub-Composables — ProfileFields, PreferenceControls, LanguageSelector — each receive the same state and onStateChange and render a slice of the form. The grouping is just visual organisation; functionally they could all be inline.

Splitting into named Composables makes the structure of the form explicit and gives you natural seams to test, restyle, or rearrange.

OutlinedTextField with two-way binding

OutlinedTextField(
  value = state.name,
  onValueChange = { onStateChange(state.copy(name = it)) },
  label = { Text("Name") },
  singleLine = true,
  modifier = Modifier.fillMaxWidth()
)

Two arguments do the work.

value = state.name — what the field displays.

onValueChange = { onStateChange(state.copy(name = it)) } — fires on every keystroke. The lambda gets the new text (it), creates a copy of state with the name field replaced, and reports it to the parent.

That's the canonical two-way binding pattern. The data class's copy(name = ...) method is auto-generated; you don't write it.

Email field with validation

val emailError = validateEmail(state.email)
OutlinedTextField(
  value = state.email,
  onValueChange = { onStateChange(state.copy(email = it)) },
  label = { Text("Email") },
  singleLine = true,
  isError = emailError != null,
  supportingText = if (emailError != null) {{ Text(emailError) }} else null,
  modifier = Modifier.fillMaxWidth()
)

val emailError = validateEmail(state.email) runs every recomposition — every keystroke. The result is null for valid emails, a message string for invalid ones.

isError = emailError != null — Material 3 styles the field's border red when this is true.

supportingText = if (emailError != null) {{ Text(emailError) }} else null — conditionally provides the small help-text under the field. The double-braces are Kotlin syntax for "lambda that returns a Composable" — it's the parameter type Material expects.

Validation appears live as the user types. No submit-time-only validation; the user knows the moment their email is wrong.

Checkbox

Row(verticalAlignment = Alignment.CenterVertically) {
  Checkbox(
    checked = state.notifications,
    onCheckedChange = { onStateChange(state.copy(notifications = it)) }
  )
  Text("Enable notifications", color = PurpleGrey80)
}

Same pattern: checked reads state, onCheckedChange reports state. Wrapped in a Row with the label so they sit horizontally and align vertically.

For clickable rows where clicking the label also toggles the checkbox, wrap the Row in a Modifier.clickable { ... } — but the basic version is enough here.

Switch

Row(
  modifier = Modifier.fillMaxWidth(),
  verticalAlignment = Alignment.CenterVertically,
  horizontalArrangement = Arrangement.SpaceBetween
) {
  Text("Dark mode", color = PurpleGrey80)
  Switch(
    checked = state.darkMode,
    onCheckedChange = { onStateChange(state.copy(darkMode = it)) }
  )
}

Arrangement.SpaceBetween pushes the label to the left and the switch to the right. iOS-settings-style row.

Radio group via forEach

val languages = listOf("English", "Spanish", "Japanese")
languages.forEach { lang ->
  Row(verticalAlignment = Alignment.CenterVertically) {
    RadioButton(
      selected = state.language == lang,
      onClick = { onStateChange(state.copy(language = lang)) }
    )
    Text(lang, color = PurpleGrey80)
  }
}

Loop over the languages, render a row per language. selected = state.language == lang lights up the one whose value matches the current state. onClick reports the new selection.

The "exactly one selected" semantics fall out naturally — every click sets the state to a single value, so only one row's selected is true at a time.

Why immutable state plus copy()

Could we instead make FormState a class with var properties and mutate fields directly? Yes — and you'd lose the structural benefits Compose relies on.

Compose decides whether to recompose by comparing state via equality (==). Two distinct immutable instances with the same field values are equal; two snapshots of a mutating object aren't. Mutable fields hide changes from the framework; the UI gets out of sync.

Plus, immutable data is easier to test, log, persist, and reason about. Compose makes idiomatic Kotlin painless: state.copy(name = "Alice") is one line.

Running it

./gradlew run. The form appears centred. Type in the Name field — recomposition happens; the value updates. Type in Email without @ — the border turns red and an error message appears. Tick the checkbox, flip the switch, pick a language. Click Save — the state prints to stdout.

Saved: FormState(name=Alice, email=alice@example.com, notifications=true, darkMode=false, language=Japanese)

Common mistakes

Mutating the data class. state.name = "Alice" doesn't compile — fields are val. Use state.copy(name = "Alice").

Owning state in the form, not the app. If SettingsForm calls remember { mutableStateOf(FormState()) } itself, the parent can't observe the state, can't persist it, can't pre-populate it from disk. Hoist the state up.

Validating only on submit. Users find out about errors only after clicking Save. Inline validation gives feedback instantly.

Forgetting singleLine = true on text inputs. Without it, the field grows when the user pastes a multi-line value. For names, emails, and similar single-line fields, set the property explicitly.

Using a String for an exclusive choice. Three radios writing to a String work for tutorials. For real apps, use a sealed class or enum class. Type-safe; the compiler catches typos.

What's next

Lesson 4: lists and navigation. A notes app with a LazyColumn of items and a detail screen. We add a sealed class Screen to represent which view is showing — same hoisted-state pattern, but now the state controls navigation, not just form fields.

Recap

Hoisted state: parent owns mutableStateOf(FormState()), children take state and onStateChange. Immutable data class with copy() for updates. Two-way binding on every input via value/onValueChange. Pure validation functions. Material 3 components (OutlinedTextField, Checkbox, Switch, RadioButton) all follow the same pattern.

Next lesson: lists and navigation. See you in the next one.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.