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.

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.

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:
InputTransformationfilters or constrains what gets stored — digits only, max length.OutputTransformationchanges 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.