Efficient state management for TextField in Compose
To prevent synchronization issues and unexpected behavior:
TextField
Avoid delay/asynchronous behavior between input and update state.StateFlow
Avoid using data collected by reactive streams to saveTextField
state, such as using the default dispatcher.- Use the Compose API, for example
MutableState<String>
, to defineTextField
state variables. When needed,TextField
the state is raised toViewModel
, for example, business validation is applied toTextField
the content.
Suppose we have to implement a registration page in a Jetpack Compose application and receive the following design:
We have two text input boxes and a button.
Let's start with the text input box at the top, which is the username field.
In order to implement a text input box in Compose, we need to define a state variable:
- Stores the currently displayed value and passes it to the TextField's value parameter.
- It is updated whenever the user enters new text in the TextField's onValueChange callback.
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
var myValue = ...
OutlinedTextField(
value = myValue, // #1
onValueChange = {
newValue -> myValue = newValue } // #2
...
)
}
When dealing with state, the important thing is deciding where to put the state variables. In our case, we want to do some business logic validation on the username, so we lift the state into the ViewModel instead of keeping it in the composition function. For more information on this and how to organize your application architecture, you can read our Architecture Guide.
By putting the state in the ViewModel, the TextField value will persist for free on configuration changes.
Based on these requirements, we create a composite registration screen that contains an OutlinedTextField component similar to this:
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpScreen.kt
@Composable
fun SignUpScreen(...) {
OutlinedTextField(
...
value = viewModel.username.collectAsStateWithLifecycle(),
onValueChange = {
viewModel.updateUsername(it) }
...
)
}
}
Next, in the ViewModel, we'll define state variables and execute business logic.
Currently, it is not recommended to use Reactive Streams to define TextField's state variables. We'll explore why, and other pitfalls, in the next chapters, but for now let's say we make this mistake. We mistakenly defined a variable MutableStateFlow
of _username
to store the TextField state and exposed it by defining an immutable backed variable username.
The asynchronous method updateUsername
will call the service every time the user types new characters on the TextField to verify that the username is available (eg, has been used before). If authentication fails, it will display an error message asking to choose a different username.
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpViewModel.kt
class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() {
// DO NOT DO THIS. ANTI-PATTERN - using a reactive stream for TextField state
private val _username = MutableStateFlow("")
val username = _username.asStateFlow()
fun updateUsername(input: String) {
viewModelScope.launch {
// async operation
val isUsernameAvailable = userRepository.isUsernameAvailable(input)
// ...
if (!isUsernameAvailable) {
// modify error state
}
// DO NOT DO THIS. ANTI-PATTERN - updating after an async op
_username.value = input
}
}
}
question
We have completed the implementation of the username field. If we run the application now, we should be able to test:
when we type, we quickly discover incorrect behavior: as we type, some letters are skipped, some letters are added to the input in the wrong order, the whole Bits are repeated and the cursor jumps back and forth. All editing operations failed, including deleting and selecting text to replace. Clearly there is a bug.
What happened and how can we fix it?
Internal implementation of TextField
As of this writing (using Compose UI 1.3.0-beta01), TextField's implementation includes holding 3 copies of the state:
- Input Method Editors (IME): To be able to perform smart actions, such as suggesting the next word or emoji to replace a word, the keyboard needs to have a copy of the currently displayed text.
- A state holder defined and updated by the user, in the above example it is a MutableStateFlow variable.
- The internal state acts as a controller, keeping the other two states in sync, so you don't need to manually interact with the IME.
Even though there are 3 copies of the state in play at the full time of each TextField, the developer only manages one of them (the state holder) and the other is internal.
How do these three states interact behind the scenes? To simplify, each character typed or added from the keyboard performs a series of steps, forming a feedback loop, as follows:
- Events are entered from the keyboard (the word "hello" is entered) and are forwarded to the internal controller.
- The internal controller receives this update "hello" and forwards it to the state holder.
- The state holder is updated with "hello" content, which updates the UI and notifies the internal controller that the update has been received.
- The internal controller notifies the keyboard.
- The keyboard is notified so it can prepare for the next typing event, such as suggesting the next word.
As long as these copies of the state are kept in sync, the TextField will behave as expected.
However, by introducing asynchronous behavior and race conditions into the typing process, these copies can become out of sync and cannot be recovered. The severity of these errors depends on various factors such as the amount of delay introduced, keyboard language, text content and length, and input method implementation.
Even just using reactive flows to represent state (such as StateFlow) without latency can be problematic because dispatch of update events is not immediate if you use the default scheduler.
Let's try to see what happens in this case, when you start typing. A new event "hello" from the keyboard arrives, then we generate an asynchronous call before we update the state and UI. Then another event "world" comes from the keyboard.
The first asynchronous event resumes and the loop completes. When the TextField internal state receives an asynchronous "hello", it discards the latest "hello world" it received before.
But at some point, the "hello world" asynchronous event will also resume. At this point the TextField remains in an invalid state with 3 of the states mismatching.
The internal state of the TextField is overridden to 'hello' instead of 'hello world'.
But at some point, the "hello world" asynchronous event will also resume. At this point the TextField remains in an invalid state with 3 of the states mismatching.
There is an inconsistency with TextField. Combine these unexpected asynchronous calls with the IME's handling, fast typing, timing conditions, and deletions that replace entire blocks of text, and the flaw becomes even more apparent.
Now that we have some understanding of the dynamics involved, let's look at how to fix and avoid these issues.
Best Practices for Handling TextField State
Avoid delayed state updates
Update your TextField immediately and synchronously when onValueChange is called.
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpViewModel.kt
class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() {
fun updateUsername(input: String) {
- viewModelScope.launch {
- // async operation
- val isUsernameAvailable = userRepository.isUsernameAvailable(input)
- // ...
-
- if (!isUsernameAvailable) {
- // modify error state
- }
username.value = input
}
}
}
// SignUpScreen.kt
@Composable
fun SignUpScreen(...) {
OutlinedTextField(
value = viewModel.username,
onValueChange = {
username -> viewModel.updateUsername(username) }
…
)
}
You may still need to filter or trim the text. Synchronous operations can be performed. Consider using , for example, if your synchronous operations convert input to a different character set visualTransformation
. You should avoid using asynchronous operations as this can lead to the problems described above.
Use MutableState to represent TextField state
Avoid using reactive flows such as StateFlow to represent TextField state because these constructs introduce asynchronous latency. Instead MutableState should be used:
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
class SignUpViewModel : ViewModel() {
var username by mutableStateOf("")
private set
// ...
}
If you still prefer to use StateFlow to store state, make sure to use the immediate scheduler instead of the default scheduler to collect from the flow.
This solution requires deeper knowledge of coroutines and can lead to the following problems:
- Since the collection is synchronous, the UI may be in an inoperable state while it is happening.
- Interfere with Compose's threading and rendering phases, since it assumes recomposition happens on the main thread.
Where to define the state
If your TextField state needs business logic validation while typing, it is correct to promote the state into the ViewModel. If you don't need that, you can use Composables or state holding classes as real data sources.
The general rule is that you should place state as low as possible while still being properly owned, which usually means closer to where it is used. For more information on state in Compose, check out our guide.
When solving this problem, the important thing is not where to promote the TextField state, but how to store it.
Apply best practices to your application
With these best practices in mind, let's implement both asynchronous and synchronous validation into our TextField state.
Starting with asynchronous validation, if the username to be used is invalid, we want to display an error message below the TextField and perform this validation on the server side. In our UI it will look like this:
When called onValueChange
, we will immediately call the update method to update TextField
, and the ViewModel will then schedule an asynchronous check based on the value just changed.
In the ViewModel, we define two state variables: one for the state of the TextField username
, MutableState
and one userNameHasError
for StateFlow
reactive calculations when the username is updated.
snapshotFlow
The API transforms the Compose State into flow
so that we can perform asynchronous (pending) operations on each value.
Because typing can be faster than getting asynchronous call results, we process events sequentially and use mapLatest
(experimental) cancellation of outstanding calls when new events come in to avoid wasting resources or displaying incorrect status. We can also add a debounce method (delay between asynchronous calls) for the same reason.
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpViewModel.kt
@OptIn(ExperimentalCoroutinesApi::class)
class SignUpViewModel(private val signUpRepository: SignUpRepository) : ViewModel() {
var username by mutableStateOf("")
private set
val userNameHasError: StateFlow<Boolean> =
snapshotFlow {
username }
.mapLatest {
signUpRepository.isUsernameAvailable(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
fun updateUsername(input: String) {
username = input
}
}
// SignUpScreen.kt
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun SignUpScreen(...)
OutlinedTextField(
value = viewModel.username,
onValueChange = {
newValue ->
viewModel.updateUsername(newValue)
}
)
val userNameHasError by viewModel.userNameHasError.collectAsStateWithLifecycle()
if (userNameHasError) {
Text(
text = "Username not available. Please choose a different one.",
color = Color(ColorError)
)
}
...
}
Note that we are collecting error validation streams using the experimental collectAsStateWithLifecycle API, which is the recommended way to collect streams in Android. To learn more about this API, you can check out the "Consuming Streams Safely" section in the Jetpack Compose blog post.
https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3
Now, we want to add synchronous validation to check if the input contains invalid characters. We can use the synchronous derivedStateOf() API which will trigger the lambda validation whenever the username changes.
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpViewModel.kt
class SignUpViewModel(private val signUpRepository: SignUpRepository) : ViewModel() {
var username by mutableStateOf("")
private set
val userNameHasLocalError by derivedStateOf {
// synchronous call
signUpRepository.isUsernameCorrect(username)
}
...
}
derivedStateOf()
Creates a new State that read userNameHasLocalError
components will reassemble when the value changes between true and false.
Our full authenticated username implementation looks like this:
Consider the implementation of TextField
We are currently improving the TextField API and consider it one of our priorities.
The Compose roadmap reflects the team's work on several fronts, in this case text editing and keyboard input improvements that relate to these APIs. So keep an eye out for future Compose releases and release notes.