The New Compose TextField State APIs

AndroidCompose
Share on LinkedIn

Compose text input spent years on the TextFieldValue(text, selection, composition) triplet—fine for basic cases, awkward for formatted input, undo stacks, and observing changes without boxing every keystroke into a new data class. TextFieldState and its buffer API are the replacement path Material and Foundation are betting on for new code. If you are building forms in 2025, start here—not another wrapper around mutableStateOf("").

Basic TextFieldState usage

@Composable
fun EmailField(state: TextFieldState, modifier: Modifier = Modifier) {
    BasicTextField(
        state = state,
        modifier = modifier,
        lineLimits = TextFieldLineLimits.SingleLine,
        inputTransformation = InputTransformation.then(KeyboardOptions(capitalization = KeyboardCapitalization.None)),
    )
}

@Composable
fun SignUpScreen() {
    val emailState = rememberTextFieldState()
    val passwordState = rememberTextFieldState()

    Column {
        EmailField(state = emailState)
        PasswordField(state = passwordState)

        Button(onClick = {
            submit(emailState.text.toString(), passwordState.text.toString())
        }) {
            Text("Sign up")
        }
    }
}

rememberTextFieldState(initialText = "") survives recomposition; hoist to ViewModel for process death with saved state hooks.

Observing text changes

val state = rememberTextFieldState()

LaunchedEffect(state) {
    snapshotFlow { state.text.toString() }
        .debounce(300)
        .collect { query -> viewModel.search(query) }
}

snapshotFlow reads snapshot state efficiently—better than onValueChange forwarding for debounced search.

Output transformation (phone mask)

@OptIn(ExperimentalFoundationApi::class)
fun phoneOutputTransformation(): OutputTransformation {
    return OutputTransformation { buffer ->
        val digits = buffer.text.filter { it.isDigit() }.take(10)
        val formatted = buildString {
            digits.forEachIndexed { i, c ->
                if (i == 3 || i == 6) append('-')
                append(c)
            }
        }
        if (buffer.text.toString() != formatted) {
            buffer.replace(0, buffer.length, formatted)
        }
    }
}

BasicTextField(
    state = state,
    outputTransformation = phoneOutputTransformation(),
    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
)

Stored text matches displayed formatted text with output transformation—unlike VisualTransformation which hides formatting from the model.

Input transformation (validation)

val digitsOnly = InputTransformation { _, proposed ->
    proposed.filter { it.isDigit() }
}

BasicTextField(state = state, inputTransformation = digitsOnly)

Reject characters before they enter state—cleaner than fixing text in onValueChange after the fact.

Material3 TextField integration

OutlinedTextField(
    state = emailState,
    label = { Text("Email") },
    modifier = Modifier.fillMaxWidth(),
)

Material wrappers pass through to state-based BasicTextField internally in recent Material3 versions.

Hoisting to ViewModel

class EditProfileViewModel : ViewModel() {
    val nameState = TextFieldState(initialText = "")
    val bioState = TextFieldState(initialText = "")

    fun load(profile: Profile) {
        nameState.edit { replace(0, length, profile.name) }
        bioState.edit { replace(0, length, profile.bio) }
    }
}

Use TextFieldState.edit { } batch mutations for atomic buffer updates with single notification.

Undo/redo

TextFieldState maintains undo history when edits go through the buffer API:

IconButton(onClick = { state.undo() }) {
    Icon(Icons.AutoMirrored.Filled.Undo, "Undo")
}

Programmatic state.edit calls can merge into undo stack with appropriate flags—check API version docs for InputTransformation undo behavior.

Migration from TextFieldValue

TextFieldValue TextFieldState
value.copy(text = new) state.edit { replace(...) }
onValueChange = { value = it } pass state directly
VisualTransformation OutputTransformation for model sync
Selection via TextRange state.selection property

Bridge period—both APIs coexist. Migrate screen by screen.

Common pitfalls

Reading state.text during composition without snapshot subscription. Use state.text.toString() inside TextField or collect via snapshotFlow—direct reads work but trigger recomposition every keystroke (usually intended in the field itself).

Replacing state instance on config change. Use rememberSaveable with Saver or hold in ViewModel—do not recreate state losing user input.

Mixing controlled patterns. Do not also pass legacy value/onValueChange with state—pick one API surface.

TextFieldState in ViewModel

Hoist state to ViewModel for form validation and submission:

class LoginViewModel : ViewModel() {
    val emailState = TextFieldState()
    val passwordState = TextFieldState()

    val isValid: StateFlow<Boolean> = snapshotFlow {
        emailState.text.contains("@") && passwordState.text.length >= 8
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)

    fun submit() {
        if (!isValid.value) return
        authRepository.login(emailState.text.toString(), passwordState.text.toString())
    }
}

TextFieldState survives configuration changes when held in ViewModel — no rememberSaveable needed.

InputTransformation for formatting

Format input as user types without changing the underlying model:

val phoneState = TextFieldState()

BasicTextField(
    state = phoneState,
    inputTransformation = InputTransformation.byValue { _, proposed ->
        // Allow only digits, max 10
        proposed.filter { it.isDigit() }.take(10)
    },
    outputTransformation = OutputTransformation {
        // Display as (XXX) XXX-XXXX
        val digits = it.text.filter { c -> c.isDigit() }
        if (digits.length >= 6) {
            replace(0, it.length, "(${digits.take(3)}) ${digits.drop(3).take(3)}-${digits.drop(6)}")
        }
    }
)

InputTransformation filters/modifies on input. OutputTransformation formats display without changing stored value.

Accessibility with TextFieldState

TextFieldState integrates with Compose semantics:

OutlinedTextField(
    state = emailState,
    label = { Text("Email") },
    modifier = Modifier.semantics {
        contentDescription = "Email address input"
        if (emailState.text.isEmpty()) {
            error("Email is required")
        }
    }
)

Set semantics error state based on validation — TalkBack announces errors on focus. Pair with isError parameter for visual error indicator.

Failure modes

Production checklist

Common production mistakes

Teams get text field state 2026 wrong in predictable ways:

Compose UI work on text field state 2026 janks when recomposition scope is too wide, remember keys omit stable inputs, and semantics for TalkBack are added only after QA finds focus traps.

Resources

Frequently asked questions

What is TextFieldState in Compose?

TextFieldState is a mutable text input state class replacing TextFieldValue for new code. It stores text, selection, and composition in a single observable object with efficient change notifications. It integrates with BasicTextField overloads that accept state directly and supports output transformations for formatting like phone masks without mutating stored text.

Should I migrate from TextFieldValue to TextFieldState?

New screens should use TextFieldState. Existing TextFieldValue code continues working—migration is incremental. TextFieldState shines with output transformations, undo history, and kotlinx.coroutines Flow observation via snapshotFlow. Material3 TextField adds state-based overloads alongside legacy value/onValueChange.

How do output transformations differ from VisualTransformation?

VisualTransformation only changes display—the underlying text stays raw. OutputTransformation formats text as users type (adding dashes to phone numbers) while keeping a separate visual representation. InputTransformation validates or filters incoming characters before they enter the buffer.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →