TextField & User Input

RecipeBox's Add/Edit Recipe screen is the first place we need real text input — a title, a prep time, ingredients, steps. Compose's TextField and OutlinedTextField look like they'd "just work" the way an XML EditText with a bound variable might, but Compose has an important difference worth understanding up front: there is no two-way data binding. You always have to wire the value up yourself.

TextField and OutlinedTextField

Both composables render a text input; OutlinedTextField draws a bordered outline around the field (Material 3's more common look), while TextField uses a filled background instead. Which one you pick is purely visual — everything else about how they work is identical:

@Composable
fun RecipeTitleInput() {
    var title by remember { mutableStateOf("") }

    OutlinedTextField(
        value = title,
        onValueChange = { title = it },
        label = { Text("Recipe title") },
        modifier = Modifier.fillMaxWidth()
    )
}

The Mandatory value / onValueChange Pair

This is the core rule: TextField is a completely "dumb" display of whatever String you pass into value — it does not remember what the user typed on its own. Every keystroke fires onValueChange with the field's new proposed text, and it is your responsibility to store that new text somewhere and feed it back into value. If you forget the onValueChange half, the field will visually reject every keystroke, because value never changes and Compose keeps re-rendering the same old text:

// Broken: value never updates, so the field appears frozen — you cannot type into it
var title by remember { mutableStateOf("") }
OutlinedTextField(value = title, onValueChange = { /* forgot to update title! */ })

// Correct: onValueChange writes the new text back into the state that value reads from
OutlinedTextField(value = title, onValueChange = { title = it })
Note: This is the price of Compose's unidirectional data flow — state always flows down (value) and events always flow up (onValueChange). It's more typing than XML's two-way android:text="@={viewModel.title}" data-binding, but it makes the data flow through your composable completely explicit and traceable.

KeyboardOptions: Choosing the Input Type

RecipeBox's prep-time field should only accept digits, and showing the phone's numeric keypad instead of the full alphabet keyboard is both friendlier and reduces bad input. KeyboardOptions configures exactly that:

@Composable
fun PrepMinutesInput(prepMinutesText: String, onPrepMinutesChange: (String) -> Unit) {
    OutlinedTextField(
        value = prepMinutesText,
        onValueChange = onPrepMinutesChange,
        label = { Text("Prep time (minutes)") },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
        modifier = Modifier.fillMaxWidth()
    )
}

Other common KeyboardType values include Email, Password (which also needs a visualTransformation to mask characters), and Text (the default). Note that KeyboardType.Number only changes which keyboard is shown — it doesn't stop a pasted non-numeric string from arriving in onValueChange, so real validation still needs to happen in your own code, as the next section covers.

Worked Example: Add Recipe Title Field with Validation

A recipe's title field is required — RecipeBox shouldn't let someone save a recipe with a blank name. OutlinedTextField has built-in support for an error state via isError, plus a supportingText slot for the message itself:

@Composable
fun RecipeTitleField(modifier: Modifier = Modifier) {
    var title by remember { mutableStateOf("") }
    var touched by remember { mutableStateOf(false) }
    val showError = touched && title.isBlank()

    OutlinedTextField(
        value = title,
        onValueChange = {
            title = it
            touched = true
        },
        label = { Text("Recipe title") },
        isError = showError,
        supportingText = {
            if (showError) {
                Text("Title is required")
            }
        },
        modifier = modifier.fillMaxWidth()
    )
}

touched avoids showing "Title is required" the instant the screen opens, before the user has typed anything at all — the error only appears once they've interacted with the field and left it empty. This version keeps its own state internally, which works for a quick example, but it means nothing outside this composable — like a parent "Save Recipe" button — can see what the user typed. The next lesson fixes exactly that.

Next up: State Hoisting refactors this exact RecipeTitleField into a stateless, reusable composable whose caller owns the title value.