---
title: "Building Reliable Text Editing in Compose: TextFieldState, TextFieldBuffer, and Compose 1.12 Styling"
description: "Why Compose 1.13 soft-deprecates onValueChange text fields, how to structure TextFieldState and TextFieldBuffer, and how to use TrackedRange and ExpandPolicy in Compose 1.12."
pubDatetime: 2026-11-11
author: "Arda Kazancı"
canonicalUrl: "https://ardakazanci.com/posts/building-reliable-text-editing-in-compose/"
tags: ["android", "kotlin", "compose", "architecture", "androidx"]
---
Jetpack Compose is moving away from callbacks in text fields. Starting in **Compose 1.13**, the `value` and `onValueChange` overloads across `BasicTextField`, `TextField`, and `OutlinedTextField` (`androidx.compose.foundation:foundation`) are soft-deprecated: they will no longer receive new features or bug fixes, except for critical security or accessibility issues.

```kotlin title="SoftDeprecatedTextFields.kt"
// Soft-deprecated starting in Compose 1.13
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
)
```

In this post, we look at why `TextFieldState` (stable since Compose 1.7) replaces callback-based text fields, how to design your architecture around it, and what is new for formatted text editing in **Compose 1.12**.

## Why onValueChange had to go

Using `value` and `onValueChange` with asynchronous streams like `StateFlow` creates race conditions with the software keyboard (IME). When a user types quickly, the round trip between the keyboard, the ViewModel, and Compose recomposition causes the UI and the keyboard buffer to desynchronize. This causes dropped characters, duplicated input, sudden cursor jumps, and recomposition runs on every keystroke.

`TextFieldState` fixes this by updating synchronously on the main thread as the user types:

```kotlin title="BasicTextFieldWithState.kt"
val state = rememberTextFieldState()

BasicTextField(
    state = state
)
```

`TextFieldState` holds the active editing session: text, selection range, composition markers, undo/redo history, and text styles. Because its internal properties are backed by the Compose Snapshot System, `BasicTextField` only reads state during the layout and draw phases. Reading state late avoids triggering recomposition scopes on routine typing.

## Programmatic edits, filtering, and formatting

Because `state.text` is a read-only property, all mutations pass through `TextFieldBuffer`.

### Batch edits

To modify text programmatically, call `state.edit`:

```kotlin title="BatchEdits.kt"
// Avoid separate edit calls in sequence:
// state.edit { insert(5, ",") }
// state.edit { delete(12, 13) }
// state.edit { replace(0, 5, "goodbye") }

// Batch operations inside a single atomic edit:
state.edit {
    insert(5, ",")
    delete(12, 13)
    replace(0, 5, "goodbye")
    selection = TextRange(9, 16)
}
```

Batching mutations inside one `state.edit` block allocates a single `TextFieldBuffer` instance, mutates it in place, and synchronizes with the IME and Snapshot System once. For common edits, Compose provides built-in extensions:

```kotlin title="TextFieldStateExtensions.kt"
state.clearText()
state.setTextAndPlaceCursorAtEnd("Updated text")
```

### InputTransformation

`InputTransformation` intercepts user input before it commits to `TextFieldState`:

```kotlin title="DigitsOnlyTransformation.kt"
val digitsOnly = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        revertAllChanges()
    }
}

BasicTextField(
    state = state,
    inputTransformation = digitsOnly
)
```

Instead of running a full-string diff between old and new text, you can inspect `changes` (a `ChangeList`) to locate exact edit ranges and either adjust them or revert invalid input. The IME and cursor position stay in sync automatically.

### OutputTransformation

`OutputTransformation` formats text visually without altering the raw data saved in storage:

```kotlin title="PhoneOutputTransformation.kt"
val phoneFormatting = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}

BasicTextField(
    state = state,
    outputTransformation = phoneFormatting
)
```

Unlike the legacy `VisualTransformation` API, you do not write manual `OffsetMapping` calculations. Calling `insert` on the `TextFieldBuffer` lets Compose compute the bi-directional cursor mappings automatically, avoiding index calculation crashes.

## Where to hoist your TextFieldState

Where you hoist `TextFieldState` depends on your architectural boundaries:

### 1. Local in a single composable

When an input field is self-contained and the parent screen only reads the text on form submission (such as a coupon code field), hoist state locally:

```kotlin title="PromoCodeInput.kt"
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
    val state = rememberTextFieldState()

    OutlinedTextField(
        state = state,
        inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
        trailingIcon = {
            Button(onClick = { onApply(state.text.toString()) }) {
                Text("Apply")
            }
        }
    )
}
```

`rememberTextFieldState()` uses `rememberSaveable` internally, restoring input across process death.

### 2. Common ancestor composable

When sibling components (such as a character counter or a submit button) need to inspect the text, hoist `TextFieldState` to their common ancestor:

```kotlin title="PostCommentBar.kt"
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
    val commentState = rememberTextFieldState()

    Column {
        CommentTextField(commentState)
        Row {
            Text("${commentState.text.length} / 280")
            SubmitButton(commentState)
        }
    }
}
```

Passing `TextFieldState` down does not cause the parent composable to recompose while typing. Only the scopes that read `commentState.text` invalidate.

### 3. Custom @Stable state holder

When `TextFieldState`, `InputTransformation`, and `OutputTransformation` coordinate shared state (such as an inline hashtag editor), bundle them into a dedicated `@Stable` class:

```kotlin title="TagEditorState.kt"
@Stable
class TagEditorState(
    val textFieldState: TextFieldState = TextFieldState()
) {
    private val tagRanges = mutableStateListOf<TextRange>()

    val inputTransformation = TagInputTransformation(tagRanges)
    val outputTransformation = TagOutputTransformation(tagRanges)

    fun addTag(tag: String) { /* ... */ }
}

@Composable
fun PostEditor(state: TagEditorState = remember { TagEditorState() }) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation
    )
}
```

### 4. Direct property on a ViewModel

When business logic needs continuous text access for search queries or real-time validation, expose `TextFieldState` directly from your `ViewModel` using [SavedStateHandle](/posts/savedstatehandle-savedstatecontainer/):

```kotlin title="FormViewModel.kt"
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
}
```

Never place `TextFieldState` inside an immutable data class exposed through `StateFlow`:

```kotlin title="IncorrectUiState.kt"
// Anti-pattern: do not place mutable TextFieldState inside an immutable UI state
data class FormUiState(val username: TextFieldState)
```

`TextFieldState` is a long-lived mutable reference. `StateFlow` checks reference equality and will not emit updates as the user types. Reallocating a new `TextFieldState` instance on UI state copy clears selection, cursor position, and undo history. Since `TextFieldState` holds no Android `Context` or `View` references, keeping it on a `ViewModel` maintains architectural boundaries.

### 5. One-way snapshotFlow bridge for pure Kotlin ViewModels

If your `ViewModel` lives in a pure Kotlin module without Compose dependencies, keep `TextFieldState` in the composable and bridge updates using `snapshotFlow`:

```kotlin title="LoginScreenBridge.kt"
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)

    LaunchedEffect(usernameState, viewModel) {
        snapshotFlow { usernameState.text.toString() }
            .drop(1) // Skips initial emission on first composition and configuration changes
            .collect { viewModel.onUsernameChanged(it) }
    }

    LaunchedEffect(viewModel) {
        viewModel.clearUsernameEvent.collect {
            usernameState.clearText()
        }
    }

    BasicTextField(state = usernameState)
}
```

Applying `.drop(1)` prevents re-triggering search calls or re-emitting initial values on activity recreation. If the `ViewModel` needs to clear text, emit a discrete one-off event and call `usernameState.clearText()` in the composable.

## New in Compose 1.12: Formatted text editing

`AnnotatedString` works well for static text, but reallocating immutable ranges on every keystroke causes heavy memory churn during active editing. Compose 1.12 adds direct styling APIs to `TextFieldBuffer`:

```kotlin title="BufferStylingApis.kt"
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)

fun addStyle(
    spanStyle: SpanStyle,
    start: Int,
    end: Int,
    expandPolicy: ExpandPolicy
): TrackedRange<SpanStyle>
```

When text is edited, style ranges shift, grow, shrink, and collapse automatically.

### ExpandPolicy

`ExpandPolicy` controls whether characters typed directly on style boundaries inherit the style:

- `ExpandPolicy.InsideOnly`: Boundary insertions stay unstyled; the style only grows when typing strictly inside the range.
- `ExpandPolicy.AtStart`: Insertions at the start boundary inherit the style.
- `ExpandPolicy.AtEnd`: Insertions at the end boundary inherit the style (standard editor behavior).
- `ExpandPolicy.AtBoth`: Insertions at either boundary inherit the style.

### TrackedRange and real-world examples

`addStyle` and `getSpanStyles` return a `TrackedRange` instance, which acts as a live handle to modify style boundaries in place.

#### 1. WYSIWYG Bold Toggle

```kotlin title="ToggleBoldSelection.kt"
fun TextFieldBuffer.toggleBoldSelection() {
    if (selection.collapsed) return
    val boldSpans = getSpanStyles(selection)

    if (boldSpans.none { it.spanStyle.fontWeight == FontWeight.Bold }) {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            selection.start,
            selection.end,
            ExpandPolicy.AtEnd
        )
    } else {
        val span = boldSpans.first()
        span.range = TextRange(span.start, selection.min)
    }
}
```

#### 2. Pattern-based styling with InputTransformation

```kotlin title="MarkdownBoldTransformation.kt"
object MarkdownBoldTransformation : InputTransformation {
    private val regex = Regex("""\*\*(.*?)\*\*""")

    override fun TextFieldBuffer.transformInput() {
        val matches = regex.findAll(asCharSequence()).toList()

        for (match in matches) {
            val range = match.range
            val content = match.groupValues[1]

            replace(range.first, range.last + 1, content)
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                start = range.first,
                end = range.first + content.length,
                expandPolicy = ExpandPolicy.AtEnd
            )
        }
    }
}
```

Because `InputTransformation` commits directly to `TextFieldState`, the applied bold style persists in state.

#### 3. Search highlighting with OutputTransformation

```kotlin title="SearchHighlightTransformation.kt"
class SearchHighlightTransformation(
    private val query: State<String>
) : OutputTransformation {
    override fun TextFieldBuffer.transformOutput() {
        val search = query.value
        if (search.isEmpty()) return

        val text = asCharSequence().toString()
        var index = text.indexOf(search)

        while (index != -1) {
            addStyle(
                SpanStyle(background = Color.Yellow, color = Color.Black),
                start = index,
                end = index + search.length
            )
            index = text.indexOf(search, index + search.length)
        }
    }
}
```

Because `OutputTransformation` is snapshot-aware (similar to state tracking in [CompositionObserver](/posts/observing-compose-runtime-with-compositionobserver/)), updates to `query` re-run the transformation automatically, applying visual highlights without mutating the underlying `TextFieldState`.
