This page provides examples of how you can migrate value-based TextField
s to
state-based TextField
s. See the Configure text fields page for
information on the differences between value and state-based TextField
s.
Basic usage
Value-based
@Composable fun OldSimpleTextField() { var state by rememberSaveable { mutableStateOf("") } TextField( value = state, onValueChange = { state = it }, singleLine = true, ) }
State-based
@Composable fun NewSimpleTextField() { TextField( state = rememberTextFieldState(), lineLimits = TextFieldLineLimits.SingleLine ) }
- Replace the
value, onValueChange
, andremember { mutableStateOf("")
} withrememberTextFieldState()
. - Replace
singleLine = true
withlineLimits = TextFieldLineLimits.SingleLine
.
Filtering through onValueChange
Value-based
@Composable fun OldNoLeadingZeroes() { var input by rememberSaveable { mutableStateOf("") } TextField( value = input, onValueChange = { newText -> input = newText.trimStart { it == '0' } } ) }
State-based
@Preview @Composable fun NewNoLeadingZeros() { TextField( state = rememberTextFieldState(), inputTransformation = InputTransformation { while (length > 0 && charAt(0) == '0') delete(0, 1) } ) }
- Replace the value callback loop with
rememberTextFieldState()
. - Re-implement the filtering logic in
onValueChange
usingInputTransformation
. - Use
TextFieldBuffer
from the receiver scope ofInputTransformation
to update thestate
.InputTransformation
is called exactly right after user input is detected.- Changes that are proposed by
InputTransformation
throughTextFieldBuffer
are applied immediately, avoiding a synchronization issue between the software keyboard andTextField
.
Credit card formatter TextField
Value-based
@Composable fun OldTextFieldCreditCardFormatter() { var state by remember { mutableStateOf("") } TextField( value = state, onValueChange = { if (it.length <= 16) state = it }, visualTransformation = VisualTransformation { text -> // Making XXXX-XXXX-XXXX-XXXX string. var out = "" for (i in text.indices) { out += text[i] if (i % 4 == 3 && i != 15) out += "-" } TransformedText( text = AnnotatedString(out), offsetMapping = object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { if (offset <= 3) return offset if (offset <= 7) return offset + 1 if (offset <= 11) return offset + 2 if (offset <= 16) return offset + 3 return 19 } override fun transformedToOriginal(offset: Int): Int { if (offset <= 4) return offset if (offset <= 9) return offset - 1 if (offset <= 14) return offset - 2 if (offset <= 19) return offset - 3 return 16 } } ) } ) }
State-based
@Composable fun NewTextFieldCreditCardFormatter() { val state = rememberTextFieldState() TextField( state = state, inputTransformation = InputTransformation.maxLength(16), outputTransformation = OutputTransformation { if (length > 4) insert(4, "-") if (length > 9) insert(9, "-") if (length > 14) insert(14, "-") }, ) }
- Replace the filtering in
onValueChange
with anInputTransformation
to set the max length of the input.- Refer to the Filtering through
onValueChange
section.
- Refer to the Filtering through
- Replace
VisualTransformation
withOutputTransformation
to add in dashes.- With
VisualTransformation
, you are responsible for both creating the new text with the dashes and also calculating how the indices are mapped between the visual text and the backing state. OutputTransformation
takes care of the offset mapping automatically. You just need to add the dashes in correct places using theTextFieldBuffer
fromOutputTransformation.transformOutput
's receiver scope.
- With
Updating the state (simple)
Value-based
@Composable fun OldTextFieldStateUpdate(userRepository: UserRepository) { var username by remember { mutableStateOf("") } LaunchedEffect(Unit) { username = userRepository.fetchUsername() } TextField( value = username, onValueChange = { username = it } ) }
State-based
@Composable fun NewTextFieldStateUpdate(userRepository: UserRepository) { val usernameState = rememberTextFieldState() LaunchedEffect(Unit) { usernameState.setTextAndPlaceCursorAtEnd(userRepository.fetchUsername()) } TextField(state = usernameState) }
- Replace the value callback loop with
rememberTextFieldState()
. - Change the value assignment with
TextFieldState.setTextAndPlaceCursorAtEnd
.
Updating the state (complex)
Value-based
@Composable fun OldTextFieldAddMarkdownEmphasis() { var markdownState by remember { mutableStateOf(TextFieldValue()) } Button(onClick = { // add ** decorations around the current selection, also preserve the selection markdownState = with(markdownState) { copy( text = buildString { append(text.take(selection.min)) append("**") append(text.substring(selection)) append("**") append(text.drop(selection.max)) }, selection = TextRange(selection.min + 2, selection.max + 2) ) } }) { Text("Bold") } TextField( value = markdownState, onValueChange = { markdownState = it }, maxLines = 10 ) }
State-based
@Composable fun NewTextFieldAddMarkdownEmphasis() { val markdownState = rememberTextFieldState() LaunchedEffect(Unit) { // add ** decorations around the current selection markdownState.edit { insert(originalSelection.max, "**") insert(originalSelection.min, "**") selection = TextRange(originalSelection.min + 2, originalSelection.max + 2) } } TextField( state = markdownState, lineLimits = TextFieldLineLimits.MultiLine(1, 10) ) }
In this use case, a button adds the Markdown decorations to make the text bold around the cursor or the current selection. It also maintains the selection position after the changes.
- Replace the value callback loop with
rememberTextFieldState()
. - Replace
maxLines = 10
withlineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10)
. - Change the logic of calculating a new
TextFieldValue
with aTextFieldState.edit
call.- A new
TextFieldValue
is generated by splicing the existing text based on the current selection, and inserting the Markdown decorations in between. - Also the selection is adjusted according to new indices of the text.
TextFieldState.edit
has a more natural way of editing the current state with the use ofTextFieldBuffer
.- The selection explicitly defines where to insert the decorations.
- Then, adjust the selection, similar to the
onValueChange
approach.
- A new
ViewModel StateFlow
architecture
Many applications follow the Modern app development guidelines, which
promote using a StateFlow
to define the UI state of a screen or a component
through a single immutable class that carries all the information.
In these types of applications, a form like a Login screen with text input is usually designed as follows:
class LoginViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> get() = _uiState.asStateFlow() fun updateUsername(username: String) = _uiState.update { it.copy(username = username) } fun updatePassword(password: String) = _uiState.update { it.copy(password = password) } } data class UiState( val username: String = "", val password: String = "" ) @Composable fun LoginForm( loginViewModel: LoginViewModel, modifier: Modifier = Modifier ) { val uiState by loginViewModel.uiState.collectAsStateWithLifecycle() Column(modifier) { TextField( value = uiState.username, onValueChange = { loginViewModel.updateUsername(it) } ) TextField( value = uiState.password, onValueChange = { loginViewModel.updatePassword(it) }, visualTransformation = PasswordVisualTransformation() ) } }
This design perfectly fits with the TextFields
that use the value,
onValueChange
state hoisting paradigm. However, there are unpredictable
downsides to this approach when it comes to text input. The deep synchronization
issues with this approach are explained in detail in the Effective state
management for TextField in Compose blog post.
The problem is that the new TextFieldState
design is not directly compatible
with the StateFlow
backed ViewModel UI state. It may look strange to replace
username: String
and password: String
with username: TextFieldState
and
password: TextFieldState
, since TextFieldState
is an inherently mutable data
structure.
A common recommendation is to avoid placing UI dependencies into a ViewModel
.
Although this is generally a good practice, it can sometimes be misinterpreted.
This is particularly true for Compose dependencies that are purely data
structures and don't carry any UI elements with them, like TextFieldState
.
Classes like MutableState
or TextFieldState
are simple state holders that
are backed by Compose's Snapshot state system. They are no different from
dependencies like StateFlow
or RxJava
. Therefore,we encourage you to
re-evaluate how you apply the "no UI dependencies in ViewModel" principle in
your code. Keeping a reference to a TextFieldState
within your ViewModel
is
not an inherently bad practice.
Recommended simple approach
We recommend you extract values like username
or password
from UiState
,
and keep a separate reference for them in the ViewModel
.
class LoginViewModel : ViewModel() { val usernameState = TextFieldState() val passwordState = TextFieldState() } @Composable fun LoginForm( loginViewModel: LoginViewModel, modifier: Modifier = Modifier ) { Column(modifier) { TextField(state = loginViewModel.usernameState,) SecureTextField(state = loginViewModel.passwordState) } }
- Replace
MutableStateFlow<UiState>
with a coupleTextFieldState
values. - Pass those
TextFieldState
objects toTextFields
in theLoginForm
composable.
Conforming approach
These types of architectural changes are not always easy. You may not have the
freedom to make these changes, or the time investment could outweigh the
benefits of using the new TextField
s. In this case, you can still use
state-based text fields with a little tweak.
class LoginViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> get() = _uiState.asStateFlow() fun updateUsername(username: String) = _uiState.update { it.copy(username = username) } fun updatePassword(password: String) = _uiState.update { it.copy(password = password) } } data class UiState( val username: String = "", val password: String = "" ) @Composable fun LoginForm( loginViewModel: LoginViewModel, modifier: Modifier = Modifier ) { val initialUiState = remember(loginViewModel) { loginViewModel.uiState.value } Column(modifier) { val usernameState = rememberTextFieldState(initialUiState.username) LaunchedEffect(usernameState) { snapshotFlow { usernameState.text.toString() }.collectLatest { loginViewModel.updateUsername(it) } } TextField(usernameState) val passwordState = rememberTextFieldState(initialUiState.password) LaunchedEffect(usernameState) { snapshotFlow { usernameState.text.toString() }.collectLatest { loginViewModel.updatePassword(it) } } SecureTextField(passwordState) } }
- Keep your
ViewModel
andUiState
classes the same. - Instead of hoisting the state directly into
ViewModel
and making it the source of the truth forTextFields
, turnViewModel
into a simple data holder.- To do this, observe the changes to each
TextFieldState.text
by collecting asnapshotFlow
in aLaunchedEffect
.
- To do this, observe the changes to each
- Your
ViewModel
will still have the latest values from UI, but itsuiState: StateFlow<UiState>
won't be driving theTextField
s. - Any other persistence logic implemented in your
ViewModel
can stay the same.