The text field that dropped characters on one target only
IME composition, autocorrect and hardware keyboards reach Compose differently per platform, so state hoisted too far loses keystrokes on exactly one of them.
TL;DR
- A search field dropped characters — on Android, with Gboard, in Japanese and when typing fast in English. 18% of Android search sessions contained a mangled query. Zero on iOS, desktop and web.
- The cause was not Compose and not the keyboard. It was hoisting
TextFieldValuethrough a ViewModel with aStateFlow: the IME's composing region is state the keyboard owns mid-word, and a round trip through another thread invalidates it. - The fix keeps the field's own state local and sends committed text outward — which is a different shape from "hoist all state", and the reason the usual advice fails here.
- What reading on buys: the composing-region mechanism, why only one platform shows it, and the keyboard-by-keyboard measurement.
The case this post is about
A search field, hoisted the way state hoisting is normally taught:
// feature/search/src/commonMain/kotlin/SearchViewModel.kt
class SearchViewModel(private val repo: SearchRepo, d: AppDispatchers) : BaseViewModel(d) {
private val _query = MutableStateFlow(TextFieldValue(""))
val query: StateFlow<TextFieldValue> = _query.asStateFlow()
fun onQueryChange(v: TextFieldValue) { _query.value = v }
val results = _query
.map { it.text }
.debounce(300)
.distinctUntilChanged()
.mapLatest { repo.search(it) }
.stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
}
// feature/search — the screen
@Composable
fun SearchScreen(vm: SearchViewModel) {
val query by vm.query.collectAsStateWithLifecycle()
TextField(value = query, onValueChange = vm::onQueryChange) // ← the round trip
…
}
Single source of truth, state hoisted to the ViewModel, debounced search. This is the shape every tutorial shows, and it is wrong for a text field specifically.
The symptom
Search queries arriving mangled, and only from Android:
# search sessions containing a query the user visibly corrected or abandoned, 1 week
platform sessions mangled rate
android 14,200 2,556 18.0%
ios 9,840 11 0.1%
desktop 1,120 2 0.2%
web 4,610 8 0.2%
The mangling had a shape, which is what eventually identified it:
intended arrived keyboard / language
"flights" "fligh" Gboard, English, typing fast
"flights" "flightss" Gboard, English
"東京" "とうky" Gboard, Japanese
"münchen" "mnchen" Gboard, German (umlaut via long-press)
"flights" "flights" AOSP keyboard, English — correct
Fast typing lost a trailing character or doubled one. Japanese lost the conversion entirely. And the stock AOSP keyboard was fine, which sent the first day's investigation into "it's a Gboard bug".
# where the week went
"it's a Gboard bug" 2 days (filed, then withdrawn)
"it's the debounce" 1 day (removed it; no change)
"it's recomposition" 2 days
the composing region 2 hours

Why the obvious fix failed
The first fix was to remove the debounce, on the theory that a delayed emission was racing the keyboard:
val results = _query.map { it.text }.distinctUntilChanged().mapLatest { … }
No change — the mangling happens between keystroke and field, not between field and search. The debounce is downstream of the problem.
The second fix was to hoist only the String:
private val _query = MutableStateFlow("")
TextField(value = query, onValueChange = { vm.onQueryChange(it) })
This made it worse and more interesting: cursor position now jumped to the end of the text on
every keystroke when editing mid-string, because a String-valued TextField has no selection to
preserve. The mangling rate went from 18% to 18% and a new cursor bug appeared, which is a useful
signal — it means the TextFieldValue carries something load-bearing.
The third attempt was snapshotFlow to break the synchronous link:
var local by remember { mutableStateOf(TextFieldValue("")) }
TextField(value = local, onValueChange = { local = it })
LaunchedEffect(Unit) { snapshotFlow { local.text }.collect { vm.onQueryChange(it) } }
Which works — and was arrived at by accident rather than by understanding, so it is worth saying why it works below, because the reason is the whole post.
The actual mechanism
An IME does not send characters. It maintains a composing region: a span of the text buffer that is provisional, owned by the keyboard, and revised as the user types.
keystroke committed text composing region what the field shows
f "" "f" f
l "" "fl" fl
i "" "fli" fli
(space) "fli " "" fli
For Latin fast-typing Gboard uses the composing region for autocorrect; for Japanese it holds the kana until conversion; for a long-press umlaut it holds the base character while the picker is open.
The composing region lives in the connection between the keyboard and the field. On Android that
is an InputConnection, and the keyboard issues operations against it — setComposingText,
finishComposingText, commitText — each of which assumes the buffer is in the state the previous
operation left it.
A state round trip invalidates that assumption. When onValueChange goes to a MutableStateFlow
and comes back through collectAsStateWithLifecycle, the field receives a value from outside the
input connection. Compose's TextField then has to reconcile an externally-supplied value with a
live composing region, and the only safe thing it can do is end the composition. The keyboard's next
operation then applies to a buffer it no longer understands.
Why only Android. Because only Android's IME model exposes the composing region to the field this way:
platform IME composition reaches the field as external value set mid-composition
android InputConnection ops on a live buffer composition RESTARTS → characters lost
ios UITextInput marked text; UIKit reconciles marked text preserved by UIKit
desktop hardware keys; no composing region at all nothing to invalidate
web beforeinput/compositionstart on the DOM node browser owns reconciliation
iOS and web have the same concept and their platform layer reconciles it for you. Desktop with a hardware keyboard has no composition at all — which is why desktop tests, where we do all our testing, could never show this.
And snapshotFlow works because it is one-directional. The field's value never comes back from
outside: local state is the single writer, and the ViewModel is a reader. That is the actual fix,
and "hoist the state" is the advice that breaks it — for a text field, the component's own buffer is
not duplicated state, it is the input connection's view of the world.

The fix
The field owns its buffer. The ViewModel observes committed text. Nothing writes the field's value from outside except an explicit, deliberate reset.
// feature/search/src/commonMain/kotlin/SearchScreen.kt
@Composable
fun SearchScreen(vm: SearchViewModel) {
/**
* LOCAL, and deliberately not hoisted. A TextField's TextFieldValue carries the IME's
* composing region — provisional text the keyboard owns and revises between keystrokes.
* Routing it through a StateFlow means the field receives a value from outside the input
* connection, which ends the composition; the keyboard's next operation then applies to a
* buffer it no longer understands. Measured: 18% of Android search sessions mangled,
* 0.1% on every other target, because only Android's IME model surfaces the region here.
*
* This is the one place "hoist all state to the ViewModel" is wrong: the buffer is not a
* duplicate of the ViewModel's state, it is the input connection's view of it.
*/
var field by remember { mutableStateOf(TextFieldValue("")) }
TextField(
value = field,
onValueChange = { field = it }, // the ONLY writer
singleLine = true,
)
// One-directional: the ViewModel READS committed text. Nothing flows back into `field`.
LaunchedEffect(Unit) {
snapshotFlow { field.text }.collect(vm::onQueryChange)
}
// The one case that legitimately writes the field from outside — an explicit user action,
// not a state sync. It CAN land mid-composition, and that is acceptable here: the user has
// just asked for the field to be emptied, so discarding a half-composed word is the
// intent. The distinction from the defect is that this fires on an action, not on echo.
LaunchedEffect(Unit) {
vm.clearRequests.collect { field = TextFieldValue("") }
}
val results by vm.results.collectAsStateWithLifecycle()
ResultList(results)
}
// SearchViewModel — now a consumer of text rather than the owner of a buffer
class SearchViewModel(private val repo: SearchRepo, d: AppDispatchers) : BaseViewModel(d) {
private val _query = MutableStateFlow("") // String, not TextFieldValue: the ViewModel
// has no business holding a selection.
// CONFLATED, not rendezvous: trySend on a rendezvous channel returns false and DROPS the
// event whenever the collector is not suspended at receive — which is most of the time.
private val _clearRequests = Channel<Unit>(Channel.CONFLATED)
val clearRequests: Flow<Unit> = _clearRequests.receiveAsFlow()
fun onQueryChange(text: String) { _query.value = text }
fun onClearTapped() { _clearRequests.trySend(Unit) }
val results = _query.debounce(300).distinctUntilChanged()
.mapLatest { if (it.isBlank()) emptyList() else repo.search(it) }
.stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
}
And the test, which cannot be a commonTest — which is the finding, not a limitation. The bug
lives in an InputConnection, and commonTest has no such thing; performTextInput commits
each character outright, which the ORIGINAL code also passed. The only way to drive a composing
region is an instrumented Android test holding the real connection:
// feature/search/src/androidInstrumentedTest/kotlin/SearchComposingTest.kt
//
// androidInstrumentedTest, on a device or emulator. There is no portable version of this test
// because there is no portable version of the mechanism — which is exactly why four years of
// desktop and Robolectric tests never saw it.
@Test
fun composingTextSurvivesThroughToTheViewModel() {
composeRule.setContent { SearchScreen(vm) }
composeRule.onNodeWithTag(SearchTags.Field).performClick()
// The real connection the keyboard would talk to, driven directly.
val ic = checkNotNull(inputConnectionFor(composeRule, SearchTags.Field))
ic.setComposingText("fligh", 1) // provisional
ic.setComposingText("flight", 1) // REVISED, not appended — this is the step that broke
ic.commitText("flights", 1)
composeRule.onNodeWithTag(SearchTags.Field).assertTextEquals("flights")
assertEquals("flights", vm.currentQueryForTest)
}
And the output it produced:
$ ./gradlew :feature:search:connectedAndroidTest --tests '*composingTextSurvives*'
PASSED (Pixel 7a, API 34)
$ git stash && ./gradlew :feature:search:connectedAndroidTest --tests '*composingTextSurvives*'
FAILED
assertTextEquals: expected "flights", was "flight"
(setComposingText("flight") applied to a buffer Compose had already finished)
One test, on the one target where the mechanism exists. The three targets that were always green stayed green and proved nothing, which is the part worth internalising.
$ ./tools/search-quality.sh --days 7
platform sessions mangled rate
android 13,900 31 0.2% (was 18.0%)
ios 9,610 12 0.1%
desktop 1,090 1 0.1%
web 4,480 9 0.2%
Android went from 2,556 mangled sessions a week to 31, which is the same rate as every other platform — i.e. the residual is ordinary user correction, not a defect.
How we proved it
That the keyboard was not at fault. The measurement that withdrew the Gboard bug report:
keyboard language mangle rate
Gboard English 14%
Gboard Japanese 91%
Gboard German 22%
AOSP keyboard English 0%
SwiftKey English 16%
Samsung keyboard English 19%
Three third-party keyboards all affected, the stock one not — which reads as "the stock keyboard is the correct one" and is actually "the stock keyboard uses the composing region least".
That the composing region is the mechanism. Logged at the input connection, which is where the answer was and where nobody had looked for four days:
# typing "fligh" fast, with the hoisted state
setComposingText("fligh", 1)
→ onValueChange → StateFlow → recomposition → external value set
finishComposingText() ← Compose ended the composition
setComposingText("flight", 1) ← keyboard's next op, against a buffer it no longer owns
commitText("flight") ← commits onto a buffer that already holds "fligh"
# the same typing, with local state
setComposingText("fligh", 1)
setComposingText("flight", 1)
commitText("flights")
That hoisting a String creates a second, different bug. Measured because it is the fix most
people reach for and it looks simpler:
hoisted type mangle rate cursor jumps to end?
TextFieldValue 18% no
String 18% YES — every keystroke when editing mid-string
local TextFieldValue 0.2% no
The String version proves the value carries something load-bearing: the selection.
That desktop could never have caught it. The measurement that explains five days, and the generalisable part:
our test suite, by target
desktop 184 tests ← no composing region exists on this target
android unit 22 tests ← Robolectric; no real InputConnection
on device 0 tests
Zero tests ran where the mechanism exists.
That snapshotFlow is doing one specific thing. Verified rather than left as folklore, since it
was found by accident:
design writers of field.value mangle rate
value from StateFlow two (user + flow) 18%
value local, snapshotFlow out one (user) 0.2%
value local, but a LaunchedEffect also
writes it on every query change two 17% ← reintroduced
Two writers is the defect. snapshotFlow is not magic; it is the direction.
What I cannot tell you is whether BasicTextField2/TextFieldState (the newer API) makes this
structurally impossible. It is designed around exactly this problem and holds the buffer in a state
object you pass rather than a value you set — which should remove the two-writer case by
construction. We have not migrated, so I am describing its intent rather than our measurement.
And an honest consequence. The field's text is no longer in the ViewModel, so it does not survive
process death with the rest of the ViewModel state. We accept that for a search box; for a form you
care about, the committed text needs saving separately, and the thing you save is the committed
text — never the TextFieldValue, which would restore a stale composing region.

What this generalizes to
"Hoist state to the single source of truth" is good advice that assumes state has one writer. An input field with an IME does not: the keyboard holds provisional state in the field's buffer and revises it between keystrokes, so an external write — even one that restores the exact same text — invalidates the keyboard's model. The tell for this class is a component whose value is co-authored by something outside your program, and the rule for them is direction rather than ownership: let the component own its buffer and let your state observe what it commits.
Related
- State hoisting has a limit — the same conclusion from the Compose side, where the cost is recomposition rather than lost keystrokes.
- The desktop target is the cheapest oracle you have — and the class of bug it structurally cannot see.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".