The TextField overload you're using is probably the deprecated one

Compose has two TextField APIs. The String + onValueChange pair has real problems with cursor position and asynchronous updates; the TextFieldState overload solves them and is now the recommended one.

6 min read
androidcomposekotlinmaterial3

Day 38 — The TextField overload you're using is probably the deprecat

Day 38 of 100. Text input is where state hoisting gets genuinely hard, and where Compose shipped a second API because the first one couldn't be fixed.

The symptom

A field that fights the user:

var phone by remember { mutableStateOf("") }

TextField(
    value = phone,
    onValueChange = { phone = formatPhoneNumber(it) },      // adds dashes
)

Type into the middle of an existing number and the cursor jumps to the end. Type quickly and characters arrive out of order. On a slow device with a ViewModel round-trip, letters briefly appear and then vanish.

None of it reproduces reliably, which is what makes it expensive.

Why the obvious fix fails

The obvious fix is to keep the field's own copy in sync:

var local by remember { mutableStateOf(phone) }
LaunchedEffect(phone) { local = phone }        // pull remote changes in
TextField(value = local, onValueChange = { local = it; onPhoneChange(it) })

This is Day 11's two-sources-of-truth bug, adopted deliberately. It reduces the flicker and introduces a race: the effect can overwrite what the user just typed if a state update lands between keystrokes.

It also still loses the cursor, because the String overload carries no cursor information at all. That's the part that can't be patched.

String-based TextField carries no selection; TextFieldState carries text and cursor together

The actual mechanism

A text field's state is text plus selection plus composition — the cursor position and the in-progress IME composition region. The String overload models only the first, so every recomposition where the string changes has to guess where the cursor went.

The current API keeps them together:

val state = rememberTextFieldState()

TextField(
    state = state,
    lineLimits = TextFieldLineLimits.SingleLine,
)

TextFieldState holds text and selection as one unit, mutated in place rather than replaced. No onValueChange, so there is no round-trip through your state and back, and no opportunity for the two to disagree.

Reading the value is a state read like any other:

val text = state.text                    // CharSequence, observable
LaunchedEffect(Unit) {
    snapshotFlow { state.text.toString() }
        .debounce(300)
        .collect { viewModel.search(it) }
}

That snapshotFlow + debounce is the search-as-you-type idiom, and it replaces the LaunchedEffect(query) version that restarted on every keystroke.

Formatting without fighting the cursor

The phone-number case is what InputTransformation is for — it runs as input is accepted, so the cursor stays where the user put it:

TextField(
    state = state,
    inputTransformation = InputTransformation.maxLength(10)
        .then { if (!asCharSequence().all(Char::isDigit)) revertAllChanges() },
    outputTransformation = OutputTransformation {
        if (length > 3) insert(3, "-")
        if (length > 7) insert(7, "-")
    },
)

Two distinct hooks, and the split is the point:

  • InputTransformation filters or constrains what gets stored — digits only, max length.
  • OutputTransformation changes what's displayed without touching the stored value.

So the state holds 5551234567 and the user sees 555-123-4567. Formatting no longer corrupts the underlying value, and the cursor is managed by the framework, which knows where the inserted characters went.

The old approach — reformatting inside onValueChange — could not do this, because it replaced the whole string and the field had to re-derive a cursor position from nothing.

Keyboard and IME options

Worth setting on every field, and routinely skipped:

TextField(
    state = emailState,
    keyboardOptions = KeyboardOptions(
        keyboardType = KeyboardType.Email,
        imeAction = ImeAction.Next,
        autoCorrectEnabled = false,
    ),
    onKeyboardAction = { focusManager.moveFocus(FocusDirection.Down) },
)

KeyboardType.Email gives an @ key; KeyboardType.Number gives a numeric pad; ImeAction.Next turns the return key into a field-advance. These cost one parameter each and are among the most noticeable quality differences between a form that feels considered and one that doesn't.

For passwords, the secure variant is a separate component:

SecureTextField(state = passwordState)

which disables autofill-unsafe behaviours and keeps the text out of screenshots.

Autofill is worth wiring while you're there — it's two lines and it's the difference between a login screen users tolerate and one they abandon:

TextField(
    state = emailState,
    modifier = Modifier.semantics { contentType = ContentType.EmailAddress },
)

Validation belongs after the field, not inside it

val emailState = rememberTextFieldState()
val error by remember {
    derivedStateOf {
        val t = emailState.text.toString()
        if (t.isNotEmpty() && !t.contains("@")) "Enter a valid email" else null
    }
}

TextField(
    state = emailState,
    isError = error != null,
    supportingText = { error?.let { Text(it) } },
)

derivedStateOf is right here for Day 7's reason: the text changes on every keystroke, the error message changes rarely, so the scope reading error invalidates only when the message actually changes.

Note the t.isNotEmpty() guard — validating an untouched field shows an error before the user has typed anything, which is the most common validation-UX mistake.

How to prove it

The cursor bug is directly testable, and it's the one worth keeping:

@Test fun cursorSurvivesMidStringEdit() = runComposeUiTest {
    val state = TextFieldState("5551234567")
    setContent { PhoneField(state) }
    onNodeWithTag("phone").performTextInputSelection(TextRange(3))
    onNodeWithTag("phone").performTextInput("9")
    assertEquals(4, state.selection.start)      // cursor moved by one, not to the end
}

Against the String overload with formatting, that assertion fails. That failing test is the whole argument for the migration.

On device, the manual check takes ten seconds: type into the middle of a formatted field. If the cursor jumps, you're on the old API.

Migrating

The mechanical part is small, which is the good news:

// Before
var query by rememberSaveable { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })

// After
val queryState = rememberTextFieldState()
TextField(state = queryState)

rememberTextFieldState is saveable already, so rememberSaveable goes away with the String.

Two places need thought rather than substitution. Reading the value moves from a plain variable to state.text.toString(), and if a ViewModel needs it, snapshotFlow is the bridge rather than an onValueChange callback. Setting the value programmatically — clearing a search box, filling a form from a draft — becomes an edit on the state:

state.edit { replace(0, length, draft.email) }
state.clearText()

That edit block is also the answer to "how do I set text without losing the cursor", which had no clean answer in the old API at all.

What this generalizes to

The lesson is model the whole state, not the part you're interested in. The String overload modelled text because text is what the app cares about; the cursor was the framework's business until formatting made it everyone's business.

Any time an API takes a simplified projection of some state and hands it back to you to manage, expect the discarded part to become a bug. It's the same shape as passing an id instead of an object, or a Boolean instead of a sealed state — except here the missing piece is one the user can see moving.

The migration also removes a whole category of question. "Where should the debounce go", "why does my field flicker when the ViewModel updates", "how do I clear the field without jumping the cursor" all stop being architecture problems and become one-line calls, because the state object owns the thing those questions were really about.

Tomorrow, Day 39: dialogs and bottom sheets — modality, and the state that decides whether they're showing.


Day 38 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Handling user input.