TextFieldState is a document, and edits are transactions on it

TextFieldState models text, selection and composition together. Editing through its transaction API preserves undo history and cursor position in ways string replacement cannot, which matters for any non-trivial input.

5 min read
androidcomposekotlintext

Day 52 — TextFieldState is a document; edits are transactions

Day 52 of 100. Day 38 introduced TextFieldState and the cursor problem. Today: what else it holds, and why editing it looks the way it does.

The symptom

A "clear" button that breaks undo:

IconButton(onClick = { state.setTextAndPlaceCursorAtEnd("") }) {
    Icon(Icons.Default.Close, contentDescription = "Clear")
}

The field clears. Then the user realises they wanted that text, hits undo on their keyboard, and gets nothing — or gets a state from three edits ago. The programmatic clear either wasn't recorded or wiped the history.

Same class of problem when inserting a template, applying a formatting action, or pasting a value from a picker.

Why the obvious fix fails

The obvious fix is to keep your own history:

var undoStack by remember { mutableStateOf(listOf<String>()) }

You now have two undo systems — yours and the platform's — and the keyboard's undo key still drives the platform's. They diverge on the first keystroke after a programmatic edit, and the user gets whichever one their gesture happened to reach.

The field already has an undo manager. The problem is how the edit was applied.

An edit block is one transaction: text, selection and undo entry recorded together

The actual mechanism

TextFieldState holds three things that move together:

  • text — the characters
  • selection — a TextRange; a cursor is a range of length zero
  • composition — the region the IME is currently composing, for languages with multi-keystroke input

Edits go through a transaction block, and the block is what makes them coherent:

state.edit {
    replace(0, length, "")            // buffer operations
    placeCursorAtEnd()
}

Inside edit, this is a TextFieldBuffer with replace, insert, delete, append, plus cursor and selection helpers. Everything in one block is applied as one change — one undo entry, one recomposition, one IME notification.

The convenience functions are thin wrappers on the same mechanism:

state.setTextAndPlaceCursorAtEnd(draft)   // replace all, cursor at end
state.clearText()                          // replace all with empty

Both are fine. What breaks undo is mixing paradigms — reading state.text, transforming it in your own code, and writing it back as a fresh string, which the buffer sees as a wholesale replacement with no relationship to what came before.

Undo, and when to suppress it

The undo manager is on the state:

state.undoState.undo()
state.undoState.redo()
state.undoState.clearHistory()

val canUndo = state.undoState.canUndo

Wiring these to toolbar buttons in an editor is the common use. clearHistory() is right when the document changes identity — loading a different note into the same field, where undoing into the previous note's content would be wrong.

Some edits shouldn't be undoable at all. Applying a formatting normalisation on blur, for instance, is housekeeping rather than a user action, and putting it in the history means undo appears to do nothing:

LaunchedEffect(focused) {
    if (!focused) state.edit { /* normalise whitespace */ }
}

The judgement is whether the user would recognise the change as theirs. If they wouldn't, it shouldn't be a step they can undo back through.

Selection is readable and writable

val sel = state.selection                    // TextRange(start, end)
val selectedText = state.text.substring(sel.min, sel.max)

state.edit {
    selection = TextRange(0, length)         // select all
}

This is what a rich-text toolbar needs — "bold the selection" reads state.selection, applies a change to that range, and restores a sensible cursor afterwards. With the String API the selection simply wasn't available, which is why toolbars built on it always felt approximate.

One detail: TextRange can be reversedstart > end when the user dragged right-to-left. Use .min and .max rather than .start and .end whenever you're slicing text, or you'll get an empty result for half your users' selections.

The IME contract

composition is the region an IME is actively building — the pinyin being typed before a Chinese character is chosen, or a word being predicted. Two rules follow:

Don't edit programmatically while a composition is active. It cancels the IME's in-progress input, which for CJK and Indic input methods means losing a partially typed character.

Don't validate against composing text. A half-composed word will fail nearly any validation, so an error shown mid-composition is noise. Validate on the settled value — which in practice means debouncing, as Day 43 did for search.

InputTransformation from Day 38 runs after the IME has produced characters, which is why it's the safe place to constrain input rather than filtering in a text-changed callback.

Keyboard actions

The IME action button gets its behaviour from one lambda:

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

For the last field in a form, ImeAction.Done with a submit handler completes the flow without reaching for the screen. It's a small thing that makes a form feel finished, and it's usually the difference between "the form works" and "the form is pleasant".

How to prove it

Undo behaviour is testable directly, which is the useful part:

@Test fun clearIsUndoable() {
    val state = TextFieldState("hello world")
    state.edit { replace(0, length, "") }
    assertTrue(state.undoState.canUndo)
    state.undoState.undo()
    assertEquals("hello world", state.text.toString())
}

Written against a string-replacement clear, canUndo behaves differently — and that difference is invisible in manual testing unless you specifically try undo after a programmatic edit.

For the IME rules, a physical device with a Chinese or Japanese keyboard is the only real check. If you don't have one, at minimum confirm that no code path calls state.edit from a LaunchedEffect keyed on the text itself — that's the pattern that fights the IME.

What this generalizes to

The idea is an edit is a transaction, not an assignment. Replacing a document's contents throws away the relationship between before and after; describing the change preserves it, which is what makes undo, cursor stability and collaborative editing possible at all.

Text editors, databases and version control all landed on the same shape for the same reason. state.edit { } looks like ceremony next to text = "" right up until you need any of the things the transaction was preserving.

Two state objects, one field

A detail worth knowing before you build anything complex: the content state and the decoration state are separate objects with different lifetimes.

val textState = rememberTextFieldState()                 // content — saveable
val scrollState = rememberScrollState()                  // how far the field is scrolled
val interactionSource = remember { MutableInteractionSource() }   // pressed / focused

TextFieldState holds what the user typed and should usually be hoisted or saved. interactionSource and scroll position are UI element state by Day 12's test — nobody minds if they reset — and belong in the composition.

Mixing them up produces the two familiar failures in miniature: a TextFieldState in a plain remember loses the user's text on rotation, and an interactionSource pushed into a ViewModel makes a class that needs the Compose runtime to unit-test.

Tomorrow, Day 53: fonts — downloadable fonts, variable weights, and why emoji need special handling.


Day 52 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Handle user input — state.