TextFieldState vs Value-Based TextField in Compose

Quick answer: Keep a value-based TextField when your screen already owns immutable String values and onValueChange events. Consider TextFieldState when you need one state holder for text, selection, composition, input filters, or output formatting. Android’s current documentation marks state-based Material 3 text fields Experimental, so verify the API status and exact version before making them your production default.

Compose currently supports two input models. The familiar value-based model receives a String and sends each edit through onValueChange. The newer state-based model receives a TextFieldState, which owns the text, selection, and composition state as one object. They solve similar UI problems, but they fit different architecture and formatting needs.

This article compares their trade-offs, shows the smallest examples, and explains how to migrate deliberately rather than mixing state models in one field.

The essential difference

ConcernValue-based fieldState-based field
Input stateCaller owns a String or TextFieldValueTextFieldState owns text, selection, and composition
Update pathonValueChange reports every proposed valueInput edits update the state holder directly
FilteringUsually code inside onValueChangeInputTransformation filters before input is committed
Display formattingVisualTransformation, including offset mappingOutputTransformation, with offset mapping handled for you
LinessingleLine, minLines, and maxLinesTextFieldLineLimits
Current statusLong-established Material APIMarked Experimental in current Android documentation

The official Compose text-input guide recommends state-based fields for their complete input-state handling, but also marks them Experimental. That status matters: use the model that fits your app today, and do not treat a migration as mandatory merely because a newer API exists.

Value-based TextField: explicit and familiar

A value-based field is a controlled composable. The caller gives it the current value, and it reports an updated value through onValueChange.

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 UsernameField(
    username: String,
    onUsernameChange: (String) -> Unit,
) {
    OutlinedTextField(
        value = username,
        onValueChange = onUsernameChange,
        modifier = Modifier.fillMaxWidth(),
        label = { Text("Username") },
        singleLine = true,
    )
}

This pattern fits an immutable screen state well:

data class SignInUiState(
    val username: String = "",
)

sealed interface SignInAction {
    data class UsernameChanged(val value: String) : SignInAction
}

@Composable
fun SignInForm(
    state: SignInUiState,
    onAction: (SignInAction) -> Unit,
) {
    UsernameField(
        username = state.username,
        onUsernameChange = {
            onAction(SignInAction.UsernameChanged(it))
        },
    )
}

The state holder updates state.username immediately after the event. This keeps typing responsive and makes the UI easy to inspect, test, and serialize. It is the approach used throughout the TextField practical guide and fits the state-hoisting model described in State Hoisting in Jetpack Compose.

Use TextFieldValue instead of String only when the value-based field must also preserve cursor selection or IME composition. Otherwise, a String is the simplest correct representation.

State-based TextField: input state in one holder

State-based fields take a TextFieldState instead of a value and onValueChange pair. The holder includes the text, cursor selection, and composition information.

import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable

@Composable
fun StatefulUsernameField() {
    val usernameState = rememberTextFieldState()

    TextField(
        state = usernameState,
        label = { Text("Username") },
        lineLimits = TextFieldLineLimits.SingleLine,
    )
}

Depending on the Material 3 version in your project, this code may require an experimental opt-in. The official migration guide shows the same basic replacement: swap value, onValueChange, and local mutableStateOf for rememberTextFieldState(), then replace singleLine with TextFieldLineLimits.SingleLine.

rememberTextFieldState() remembers the holder and supplies save-and-restore behavior. Its initialText is only used at initialization; changing the argument during a later recomposition does not reset the field. To update an existing state-based field programmatically, use its edit APIs rather than passing a new initial value.

Filtering input: callback versus transformation

With a value-based field, filtering often happens in the callback:

OutlinedTextField(
    value = phoneDigits,
    onValueChange = { proposed ->
        onPhoneDigitsChange(proposed.filter(Char::isDigit).take(15))
    },
    label = { Text("Phone number") },
)

That is perfectly reasonable for a straightforward form, provided the callback updates the displayed state immediately. Keep the filtering rule in the state holder when it is part of your product policy, not embedded in a reusable field.

State-based fields move input filtering to InputTransformation:

import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable

@Composable
fun DigitsOnlyField() {
    val phoneState = rememberTextFieldState()

    TextField(
        state = phoneState,
        inputTransformation = InputTransformation {
            for (index in lastIndex downTo 0) {
                if (!charAt(index).isDigit()) {
                    delete(index, index + 1)
                }
            }
        },
    )
}

The migration guide states that InputTransformation runs immediately after user input is detected and applies its TextFieldBuffer changes before the value is committed. This is useful for format-sensitive input, but it does not replace product validation: a phone number can contain only digits and still be invalid.

Formatting output without changing the stored value

Value-based fields use VisualTransformation when text must look different from the backing value. For complex formatting, you must maintain an OffsetMapping so editing and selection line up with the transformed display.

State-based fields split that work:

  • InputTransformation changes or rejects input before it is stored.
  • OutputTransformation formats what the user sees without changing the stored text.

The state-based OutputTransformation handles the offset mapping for you. That makes it attractive for credit-card groups, phone-number separators, and other formatted inputs with cursor movement. Keep the transformation small and deterministic; a screen-level formatter should not fetch remote data or decide business validity.

Where should TextFieldState live?

There is no universal answer. The current Android migration guide explicitly says that TextFieldState is a Compose Foundation state holder without UI dependencies and can be held in a ViewModel. It also notes that putting it in a ViewModel means you must handle save-and-restore yourself, because rememberTextFieldState() supplies that only when the holder is created in composition.

Use this practical decision table:

SituationGood default
A conventional form backed by immutable StateFlow UI stateKeep value-based String fields and events
A short-lived, self-contained editorCreate state with rememberTextFieldState() in the composable
Selection-heavy, formatted, or secure text inputEvaluate TextFieldState and transformations after checking experimental status
A ViewModel must own the field stateHold TextFieldState there intentionally and implement persistence separately

Avoid the tempting hybrid where a state-based field is driven back and forth from the same StateFlow<String> on every edit. Android’s migration guide calls out synchronization problems in this arrangement. Pick one owner for the editable field; synchronize a plain value outward only at a clear boundary such as submit, debounce, or persistence.

A deliberate migration path

Start with a field that has a real reason to migrate: custom formatting, selection-aware editing, or secure input. Do not change every form at once.

  1. Identify the current field’s source of truth and its validation behavior.
  2. Replace only the value/onValueChange pair with TextFieldState.
  3. Replace line-count parameters with TextFieldLineLimits.
  4. Move immediate character filtering into InputTransformation.
  5. Move display-only formatting into OutputTransformation.
  6. Test typing, deletion, paste, cursor movement, IME composition, configuration changes, and restoration.

For ordinary email, name, and notes inputs, retaining the existing value-based architecture is often the lower-risk choice. The right migration is driven by an input problem you need to solve, not by the API’s novelty.

Testing each model

Both models should be tested through user-observable behavior. Compose tests can locate a labeled field, enter text, and assert the visible result or callback effect.

composeTestRule
    .onNodeWithText("Username")
    .performTextInput("brahim")

composeTestRule
    .onNodeWithText("brahim")
    .assertExists()

For a formatted state-based field, add tests for paste, deleting in the middle, and moving the cursor across formatting characters. For a value-based form, assert that onValueChange updates the UI state immediately and that an asynchronous validation result does not overwrite fresh input.

Preview both empty and populated states in the app theme. The Compose Preview guide is useful for checking text scale, error messages, and long labels before testing on a physical device.

Common mistakes

Treating state-based input as automatically stable

The current official documentation marks state-based Material 3 fields Experimental. Check your exact dependency version and opt-in requirements, then isolate the API so a future change does not spread across unrelated forms.

Updating the value asynchronously

With value-based fields, the UI must receive the new value promptly. Do not wait for a network request or debounced validation before reflecting a keystroke.

Resetting a state-based field through initialText

initialText initializes the holder; it is not a continuously observed value. Use the holder’s edit API for an intentional reset and decide what that reset should do to cursor selection.

Duplicating editable state

Do not maintain a mutable TextFieldState and a competing String as equal sources of truth for the same field. Name the owning state, then expose values to other layers at deliberate synchronization points.

FAQ

Is TextFieldState better than onValueChange?

Not universally. TextFieldState is better suited to complex input state, transformations, and selection handling. Value-based fields remain a strong fit for immutable screen state and conventional forms.

Can I use TextFieldState in a ViewModel?

Yes. Android’s migration guide says it is a Foundation state holder without UI dependencies. If the ViewModel creates it, handle state saving and restoration separately instead of relying on rememberTextFieldState().

Should I migrate every existing field?

No. Migrate where state-based input removes a real problem, such as manual offset mapping or selection synchronization. Keep simple fields value-based when they already fit your architecture.

Why is my formatted value-based field hard to edit?

Formatting changes visible character positions. A value-based VisualTransformation must map offsets between the stored and visible values. State-based OutputTransformation can remove that mapping work, but it is currently Experimental.

Summary

Value-based fields offer explicit, immutable state flow and are an excellent default for standard forms. TextFieldState brings text, selection, composition, filtering, and output formatting into one state holder, making it compelling for complex input. Keep the experimental status visible in your decision, use one source of truth per field, and migrate only when the newer model solves a concrete input problem.