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.
// Soft-deprecated starting in Compose 1.13@Composablepublic fun BasicTextField( value: String, onValueChange: (String) -> Unit)
@Composablepublic 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:
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:
// 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:
state.clearText()state.setTextAndPlaceCursorAtEnd("Updated text")InputTransformation
InputTransformation intercepts user input before it commits to TextFieldState:
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:
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:
@Composablefun 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:
@Composablefun 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:
@Stableclass TagEditorState( val textFieldState: TextFieldState = TextFieldState()) { private val tagRanges = mutableStateListOf<TextRange>()
val inputTransformation = TagInputTransformation(tagRanges) val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { /* ... */ }}
@Composablefun 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:
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:
// Anti-pattern: do not place mutable TextFieldState inside an immutable UI statedata 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:
@Composablefun 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:
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
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
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
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.