Repository navigation
Compose Multiplatform: Fixing TextField Cursor Reset and Lag in Dialogs
Yu-Chan (Henry) Huang edited this page Oct 12, 2026
·
2 revisions
This wiki page documents the technical investigation, root cause, and architectural resolution for text input lag and cursor reset issues in Compose Multiplatform dialogs (Issue #83).
In the Account screen's "Edit Username" AlertDialog, typing inside the OutlinedTextField caused the text input cursor to unpredictably jump back to position 0 (the beginning of the string) on every keystroke.
- Newly typed characters were inserted at the front of the string instead of the cursor's current position.
- Typing felt laggy and uncoordinated.
- Severe text corruption occurred during fast typing or when using IME composition (e.g., CJK / Auto-correct).
[User Keystroke]
│
▼
[OutlinedTextField.onValueChange]
│
▼
[onEvent(OnUpdateUsernameChanged)] ──> Dispatches event to ViewModel
│
▼
[AccountViewModel._state.update] ──> Recreates top-level AccountContract.State
│
▼
[StateFlow.collectAsStateWithLifecycle()] ──> Transmits state down Compose tree
│
▼
[AlertDialog Layer] ──> Recomposes AlertDialog content
│
▼
[OutlinedTextField] ──> Receives updated string value
-
StateFlow vs. IME Selection Buffer Race Condition:
OutlinedTextField(value: String, onValueChange: (String) -> Unit)internally converts the plain string into aTextFieldValuecontaining text and cursor selection (TextRange). When typing, the Input Method Editor (IME) expects immediate, frame-synchronous state confirmation. -
Window Boundary Latency:
AlertDialogoperates on a separate window/popup layer in Compose Multiplatform. Driving the text field state viaKeystroke -> ViewModel Event -> StateFlow -> Screen Recomposition -> Dialog Recompositionintroduced a micro-frame latency across the dialog layer. -
Compose Fallback Behavior:
When
OutlinedTextFieldrecomposes, if the incomingvalue: Stringlags behind or differs from the internal IME selection buffer state, Compose loses track of the current cursor selection and falls back to resetting selection toTextRange(0). -
Unnecessary Recomposition Overhead:
Updating
AccountContract.Stateon every keystroke triggered full screen state copy operations whileAccountViewModelwas concurrently observing user profile flows (observeUser()), magnifying recomposition overhead.
Draft text inside a temporary pop-up dialog is transient UI state. It does not need to pollute the global ViewModel state on every single keystroke. The ViewModel only needs to receive the finalized text when the user clicks "Update".
Local state isolation was applied in AccountScreen.kt using rememberSaveable:
if (state.showEditUsernameDialog) {
// 1. Isolate text field draft state to the local Dialog composable scope
var editUsernameText by rememberSaveable(state.showEditUsernameDialog) {
mutableStateOf(state.editUsername)
}
AlertDialog(
onDismissRequest = { onEvent(AccountContract.Event.OnDismissEditUsernameDialog) },
title = { Text("Edit Username") },
text = {
Column {
OutlinedTextField(
// 2. Read and write directly to local state (0ms latency, 100% IME sync)
value = editUsernameText,
onValueChange = { editUsernameText = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = {
// 3. Dispatch ViewModel events ONLY when user confirms
onEvent(AccountContract.Event.OnUpdateUsernameChanged(editUsernameText))
onEvent(AccountContract.Event.OnUpdateUsername)
},
enabled = editUsernameText.isNotBlank()
) {
Text("Update")
}
},
dismissButton = {
TextButton(onClick = { onEvent(AccountContract.Event.OnDismissEditUsernameDialog) }) {
Text("Cancel")
}
}
)
}| Approach | State Scope | Recomposition Scope | IME Cursor Stability | Clean Architecture |
|---|---|---|---|---|
| Global StateFlow (On Every Keystroke) | ViewModel StateFlow
|
Entire screen & dialog tree | ❌ Unstable (cursor resets to 0) | Over-exposes draft state |
Local Composable State (rememberSaveable) |
Local Dialog Scope | Isolated OutlinedTextField only |
✅ 100% Stable | Clean separation (ViewModel gets final result) |
-
Use
rememberSaveablefor transient form inputs inside dialogs or pop-up sheets to preserve text during configuration changes (e.g., device rotation). - Defer ViewModel state dispatches until explicit user action (e.g., clicking "Save", "Update", or "Submit").
-
Key
rememberSaveableto dialog visibility (state.showDialog) so local state cleanly resets whenever the dialog is re-opened.