Skip to content
AKarda-kazancimainbrew()
Personal blog
AK
arda-kazanci › journal ›building-reliable-text-editing-in-compose.md
← Journal.md
building-reliable-text-editing-in-compose.md345 lines · Markdown · UTF-8
Markdown Preview · building-reliable-text-editing-in-compose.md Rendered
journal/building-reliable-text-editing-in-compose.md

Building Reliable Text Editing in Compose: TextFieldState, TextFieldBuffer, and Compose 1.12 Styling

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.

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.

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:

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:

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:

TextFieldStateExtensions.kt
state.clearText()
state.setTextAndPlaceCursorAtEnd("Updated text")

InputTransformation

InputTransformation intercepts user input before it commits to TextFieldState:

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:

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:

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:

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:

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:

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:

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:

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:

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

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

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

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), updates to query re-run the transformation automatically, applying visual highlights without mutating the underlying TextFieldState.

SHARE ENTRYLinkedIn ↗X ↗Markdown / AI ↗
Discussions ardakazanci/blog-comments
● GiscusView discussions on GitHub ↗
⑂Reactions & comments/ Sign in with GitHub to react or join the discussion.
Notifications Newsletter · beehiiv
✓ Active channelSubscribe directly ↗
✉Get new posts by email/ Android, Kotlin, and Compose notes. Straight to your inbox.

SUBSCRIPTION CONFIG · EMAIL DISPATCH

Stay synced with new entries

Receive practical engineering notes on Android architecture, Kotlin, and Jetpack Compose whenever a new article is published.

channel = "email"spam = falseunsubscribe = "anytime"
K subscriber.propertiesEmail address