TextField in Jetpack Compose: State, Styling, and Practical Examples

Quick answer: Use Material 3 TextField for a filled field or OutlinedTextField for a bordered field. Keep the current value in screen state, update it immediately from onValueChange, and pass callbacks into the field. Add a visible label, the matching keyboard options, an explicit error message, and a test for input and submission.

Text input is where a Compose screen stops being a static layout and starts handling user intent. A good field does more than accept characters: it explains what belongs there, opens an appropriate keyboard, reports validation failures clearly, and sends events to the state holder without taking ownership of business rules.

This guide focuses on the broadly used value-based Material 3 APIs. It covers a practical form field, filled versus outlined styling, state hoisting, keyboard actions, error states, theming, accessibility, previews, and tests.

Choose the right text-field component

Compose offers two Material 3 text-field styles and a lower-level building block:

ComponentUse it whenWhat it provides
TextFieldThe input needs stronger visual emphasisA filled Material field with an indicator line
OutlinedTextFieldA form has several inputs that need lighter visual boundariesA Material field with an outline
BasicTextFieldYour design deliberately does not follow Material’s field decorationRaw editing behavior without Material labels, placeholders, or containers

The official text-input guide recommends Material TextField when the design calls for Material styling; OutlinedTextField is its outlined variant. Start with one of those two. Rebuilding a field from BasicTextField is only worthwhile when you genuinely need a custom interaction or visual system.

The smallest useful OutlinedTextField

OutlinedTextField is a common choice for settings and account forms because the outline separates fields without making every row feel like a solid container. A controlled field receives the current value and reports each new value through a callback.

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
fun NameField(
    name: String,
    onNameChange: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    OutlinedTextField(
        value = name,
        onValueChange = onNameChange,
        modifier = modifier.fillMaxWidth(),
        label = { Text("Name") },
        singleLine = true,
    )
}

The field does not hold a second copy of name. Its caller owns the value, so a preview, a test, and a screen backed by a ViewModel can all reuse it. For a short-lived component demo, the caller may use local state; for real screen or application state, keep the value in the screen state holder.

Hoist form state and emit actions

Each keystroke is a UI event. The screen renders the resulting state and forwards changes to the ViewModel or another state holder. Validation, persistence, and network work belong outside the composable.

data class AccountUiState(
    val email: String = "",
    val emailError: String? = null,
    val isSaving: Boolean = false,
)

sealed interface AccountAction {
    data class EmailChanged(val value: String) : AccountAction
    data object Submit : AccountAction
}

@Composable
fun AccountForm(
    state: AccountUiState,
    onAction: (AccountAction) -> Unit,
) {
    EmailField(
        email = state.email,
        errorMessage = state.emailError,
        enabled = !state.isSaving,
        onEmailChange = { onAction(AccountAction.EmailChanged(it)) },
        onSubmit = { onAction(AccountAction.Submit) },
    )
}

When EmailChanged reaches the state holder, update state.email promptly so the next composition shows the text the user just typed. Do not delay that update for a server-side check. If validation is asynchronous, keep the field responsive and represent the eventual result separately in emailError or another explicit UI-state property.

This is the same unidirectional pattern used in State Hoisting in Jetpack Compose: the UI renders state and sends events, while the ViewModel owns application decisions.

Configure keyboard type and IME actions

Keyboard options improve the input experience, but they are requests to the software keyboard rather than guarantees. Choose the keyboard type and action that match the data, then handle the action as a normal UI event.

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardActions
import androidx.compose.ui.text.input.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType

@Composable
fun EmailField(
    email: String,
    errorMessage: String?,
    enabled: Boolean,
    onEmailChange: (String) -> Unit,
    onSubmit: () -> Unit,
) {
    OutlinedTextField(
        value = email,
        onValueChange = onEmailChange,
        modifier = Modifier.fillMaxWidth(),
        enabled = enabled,
        label = { Text("Email address") },
        placeholder = { Text("name@example.com") },
        singleLine = true,
        isError = errorMessage != null,
        supportingText = {
            Text(errorMessage ?: "We only use this to contact you.")
        },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Email,
            imeAction = ImeAction.Done,
        ),
        keyboardActions = KeyboardActions(
            onDone = { onSubmit() },
        ),
    )
}

Use ImeAction.Next for a field that moves to the next input, and ImeAction.Done when it completes the current form action. The official documentation lists keyboard options such as capitalization, autocorrect, keyboard type, and IME action. Test on a real device because keyboard apps can vary in how they honor those hints.

Do not treat onDone as a substitute for a visible submit button. Some users do not use the software keyboard, and the button gives the form a clear, discoverable action. The Material 3 buttons guide shows how to model enabled and loading states for that action.

Show validation without hiding the explanation

isError = true applies the Material error treatment, but color alone is not an explanation. Pair it with concise supporting text that tells the user what to fix.

@Composable
fun PasswordConfirmationField(
    confirmation: String,
    mismatchMessage: String?,
    onConfirmationChange: (String) -> Unit,
) {
    OutlinedTextField(
        value = confirmation,
        onValueChange = onConfirmationChange,
        modifier = Modifier.fillMaxWidth(),
        label = { Text("Confirm password") },
        singleLine = true,
        isError = mismatchMessage != null,
        supportingText = {
            if (mismatchMessage != null) {
                Text(mismatchMessage)
            }
        },
    )
}

The composable above only receives an already-computed message. For example, a ViewModel can decide whether the two password values differ after each change or after the user attempts submission. Keep that policy consistent across the form: inline validation is useful when it prevents a clear mistake, while an error shown before the user has had a chance to finish typing can be distracting.

For real passwords, do not treat a visual transformation as a complete security solution. Android’s newer SecureTextField is built on the state-based API; use the official text-field guidance and verify the current Material 3 experimental status and version before adopting it. This blog’s next planned topic covers the state-based API and custom savers in more detail.

Filled versus outlined fields

The choice is visual hierarchy, not functionality. Both styles accept the same essential value, callback, label, placeholder, keyboard, and error parameters.

@Composable
fun SearchField(
    query: String,
    onQueryChange: (String) -> Unit,
) {
    TextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = Modifier.fillMaxWidth(),
        label = { Text("Search") },
        placeholder = { Text("Search saved items") },
        singleLine = true,
    )
}

Use a filled TextField for a primary search, a focused single input, or a surface where stronger emphasis helps. Use OutlinedTextField when several fields appear together and each needs a clear boundary. Keep one style consistent within a form unless the hierarchy has a deliberate reason to differ.

For a custom full-screen search experience, consider the Material search components rather than placing an ordinary field in every app bar. The field’s role should match the task, not only the available API.

Read-only, disabled, and multiline input

readOnly and enabled communicate different conditions:

PropertyUser can focus and copy textUser can editField appears available
readOnly = trueYesNoYes, as read-only content
enabled = falseNoNoNo, disabled to users and accessibility services

The Material 3 API reference specifies that disabled fields do not respond to input and appear disabled to accessibility services, while read-only fields can still receive focus and allow copying. Use readOnly for pre-filled data a user should be able to inspect; use enabled = false only when the field is temporarily unavailable.

For a message, note, or description, allow multiple lines and set a sensible range:

@Composable
fun NotesField(
    notes: String,
    onNotesChange: (String) -> Unit,
) {
    OutlinedTextField(
        value = notes,
        onValueChange = onNotesChange,
        modifier = Modifier.fillMaxWidth(),
        label = { Text("Notes") },
        placeholder = { Text("Add optional details") },
        minLines = 3,
        maxLines = 6,
    )
}

Do not set singleLine = true and then expect maxLines to create a multiline field. For value-based fields, singleLine takes precedence. Keep the field height predictable and let the surrounding screen scroll when a form is longer than the viewport.

Add icons, prefixes, and trailing actions carefully

Material fields offer leadingIcon, trailingIcon, prefix, and suffix slots. These are useful when they clarify the format—for example, a currency prefix or a visibility toggle—but they should not replace the label.

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton

@Composable
fun ClearableSearchField(
    query: String,
    onQueryChange: (String) -> Unit,
) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = Modifier.fillMaxWidth(),
        label = { Text("Search") },
        singleLine = true,
        trailingIcon = {
            if (query.isNotEmpty()) {
                IconButton(onClick = { onQueryChange("") }) {
                    Icon(
                        imageVector = Icons.Filled.Clear,
                        contentDescription = "Clear search",
                    )
                }
            }
        },
    )
}

The clear button emits the same change callback as typing, so the state holder remains the single source of truth. Give an interactive icon its own localized contentDescription. For a decorative icon that adds no new meaning, use contentDescription = null.

Style fields through the theme first

Material 3 fields read colors, shapes, and typography from MaterialTheme. Start there so light, dark, and dynamic color remain coherent. If a particular screen needs a custom treatment, use the defaults factory for that component rather than hard-coding every color.

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults

OutlinedTextField(
    value = value,
    onValueChange = onValueChange,
    label = { Text("Project name") },
    shape = MaterialTheme.shapes.medium,
    colors = OutlinedTextFieldDefaults.colors(
        focusedBorderColor = MaterialTheme.colorScheme.primary,
        focusedLabelColor = MaterialTheme.colorScheme.primary,
        errorBorderColor = MaterialTheme.colorScheme.error,
    ),
)

The MaterialTheme guide explains semantic color roles. Pair custom container and content colors intentionally, and preview focused, error, disabled, light, and dark states before shipping. Do not use a custom color merely to make a field look different from the rest of the form.

State-based fields: useful, but verify the status

Current Android documentation describes a newer state-based TextField API built around TextFieldState. It manages text, selection, and composition together, and separates input filtering from output formatting. The documentation also marks that API Experimental, so its usage may require an opt-in annotation and can change before stabilization.

For an application that needs selection-aware editing, input/output transformations, or SecureTextField, read the official state-based text-field documentation and check your exact Material 3 version. For a conventional form that already models text in a ViewModel, the value-based examples in this guide remain clear and dependable. Do not mix both state models for the same field without a deliberate migration plan.

Accessibility, focus, and previews

Use a visible label even when the field has a placeholder. A label persists as context after the user enters text; a placeholder is supplemental guidance that disappears. Keep errors in supporting text, rather than relying only on a red outline, and make trailing controls reachable with an explicit action name.

When a form has multiple fields, choose keyboard Next and focus behavior that follows the visual reading order. Test at large font scales and with a hardware keyboard, especially when a field has a leading icon, a long label, or a trailing action. Do not request focus automatically unless it is clearly expected, such as after opening a search screen.

Preview meaningful states under the app theme:

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Preview(showBackground = true)
@Composable
private fun EmailFieldErrorPreview() {
    AppTheme {
        EmailField(
            email = "name@example",
            errorMessage = "Enter a valid email address.",
            enabled = true,
            onEmailChange = {},
            onSubmit = {},
        )
    }
}

The Compose Preview guide has more ways to test device sizes, font scales, and color schemes before you run the screen.

Test input and submission behavior

Compose UI tests can type into a field and verify its visible result. Keep the test focused on observable behavior rather than an implementation detail inside the field.

composeTestRule
    .onNodeWithText("Email address")
    .performTextInput("name@example.com")

composeTestRule
    .onNodeWithContentDescription("Clear search")
    .assertDoesNotExist()

For a form test, render a test state holder, type text, assert the callback or resulting UI state, then click the visible submit button. Add a separate test for the error message and for a disabled save action. If a field needs a stable test selector beyond its label, add a testTag at the screen level with a name that describes the user’s task.

Common mistakes

Keeping a second local value

If a field accepts value from the screen and also keeps a separate remember value internally, the two can drift apart after a reload, validation update, or navigation event. Use one source of truth and forward onValueChange.

Doing validation or network work inside onValueChange

onValueChange can run for every edit. Dispatch the new text to the state holder, and debounce or validate outside the field when the product needs it. The composable should stay responsive and free of repository or network logic.

Using a placeholder as the only label

A placeholder disappears once text exists, so it cannot provide ongoing context. Use a label for the field’s identity and reserve the placeholder for an example or format hint.

Turning off a field when it should be read-only

Disabled content cannot be focused or copied. Prefer readOnly when users need to inspect a pre-filled value, and explain why editing is unavailable if that is not obvious.

Forgetting an error message

An error color signals that something is wrong but not how to fix it. Show concise supporting text and keep it close to the relevant field.

FAQ

Should I use TextField or OutlinedTextField?

Use TextField when the input needs filled, higher-emphasis styling. Use OutlinedTextField for a form with several fields or a lighter visual boundary. Both support the same core value, callback, label, keyboard, and error APIs.

Where should TextField state live?

For application and screen state, keep the value in a ViewModel-backed UI state and pass it into the field. A local state holder is fine for a self-contained demo or a Compose-owned implementation detail, but do not use it as a second source of truth for a screen form.

How do I make a text field read-only?

Pass readOnly = true. This prevents edits while allowing users to focus and copy the field’s text. Use enabled = false only when the field should be unavailable altogether.

Can I validate on every keystroke?

Yes, if the feedback is helpful and does not punish incomplete input. Dispatch the change immediately, calculate validation in the state holder, and show a clear message only when the product’s validation timing calls for it.

Do I need to use the state-based API now?

Not for every form. Android documentation currently marks state-based text fields Experimental. Use the value-based API when it suits your architecture, and evaluate the state-based API deliberately when you need its selection, transformation, or secure-input capabilities.

Summary

Build Compose fields as controlled UI: render a value, emit a change, and let the state holder decide validation and submission. Choose TextField or OutlinedTextField for visual hierarchy, give every field a persistent label, request the right keyboard, explain errors in text, and keep styling tied to MaterialTheme. With those foundations in place, a form is easier to test, accessible at large font sizes, and ready for later work on validation, focus, and state-based input.