Effective state management for TextField in Compose
- To prevent synchronization issues and unexpected behaviors:
– Avoid delays/asynchronous behaviors between typing and updating
– Avoid holding
TextFieldstate using a reactive stream (e.g. collecting from
StateFlowusing the default dispatcher)
TextFieldstate variables with Compose APIs like
TextFieldstate to the
ViewModelwhen needed e.g. applying business validations to the
- We’re working on improving
TextFieldcapabilities. Check goo.gle/compose-roadmap and look out for future releases.
Let’s say we have to implement a sign up screen in a Jetpack Compose app, and we receive the following design for it:
- Stores the current value that is displayed and we pass to
- Gets updated whenever the user inputs new text in the
When working with state, an important thing to decide is where to place the state variable. In our case, we want to perform some business logic validations on the username, therefore we hoist the state to the
ViewModel instead of holding it in the composable function. For more information about this and how to organize your app architecture, you can read our architecture guide.
By placing our state in the
TextField value will be persisted on configuration changes for free.
Given these requirements, we create a Composable sign up screen containing an
OutlinedTextField similar to this:
Next, in the
ViewModel we’ll define the state variable and perform the business logic.
At the moment, it is not recommended to use reactive streams to define the state variable for
TextField. We’ll explore why and deep dive into these and other pitfalls in the following sections but for now, imagine we make this mistake. We incorrectly define a variable
_username of type
MutableStateFlow to store the
TextField state and expose it by defining the immutable backed variable
The asynchronous method
updateUsername will call a service to verify if the username is available (e.g. hasn’t been used before) every time the user types a new character on the
TextField. If validation fails, it will show an error message asking to pick a different username.
We’re finished implementing the username field. If we run the app now we should be able to test it:
As we type, we quickly notice incorrect behaviors: some letters are skipped as we type, some are added in the wrong order to the input, entire bits are duplicated, the cursor jumps back and forth. All edit operations are failing including deletion and selecting text to replace. There is clearly an error.
What is happening and how do we fix it?
At the time of writing (using Compose UI 1.3.0-beta01), the implementation of a
TextField involves holding 3 copies of state:
- IME (input method editor): to be able to perform smart actions like suggesting next word or emojis that replace a word, the keyboard requires to have a copy of the text that is currently displayed.
- State holder defined and updated by the user, in the example above it is a
- Internal state acting as a controller, keeps the other two states in sync so you don’t have to manually interact with the IME.
Even when 3 copies of state are at play for each
TextFieldat all times, the developer manages only one (the state holder) and the others are internal.
How do these 3 states interact with each other under the hood? To simplify, every character typed or word added from the keyboard, trigger a series of steps which constitutes a feedback loop, as follows:
- An event comes in from the keyboard (the word “hello” is typed), and is forwarded to the internal controller.
- The internal controller receives this update “hello” , forwards to the state holder.
- The state holder is updated with “hello” content, which updates the UI, and notifies the internal controller that it has received the update.
- The internal controller notifies the keyboard.
- The keyboard is notified, so it can prepare for the next type event, for instance, suggesting the next word.
As long as these copies of state are kept in sync,
TextField will work as expected.
However, by introducing asynchronous behaviors and race conditions to the process of typing, these copies can fall out of sync, unable to recover. The severity of the bugs depend on various factors such as the amount of delay introduced, the keyboard language, the text content and length, and the IME implementation.
Even just using reactive streams to represent your state (e.g.
StateFlow) without delays might cause issues as the update event dispatch is not immediate if you’re using the default dispatcher.
Let’s try to see what happens in this case, as you start typing. A new event “hello” comes from the keyboard, and before we can update our state and our UI, we generate an async call. Then another event “world” comes from the keyboard.
The first asynchronous event resumes, and the loop completes. When
TextField internal state receives the async “hello”, it discards the latest “hello world” it had previously received.
But at some point the “hello world” asynchronous event will resume too. And now
TextField holds an invalid state, where the 3 states do not match.
These unexpected asynchronous calls combined with the IME’s processing, fast typing, timing conditions and operations like deletion that replaces entire blocks of the text, the bugs only become more noticeable.
Now that we understand a bit more about the dynamics at play, let’s see how we can fix and avoid these issues.
Best practices for handling TextField state
Avoid delays to update the state
onValueChange is called, update your
TextField synchronously and immediately.
You might still need to filter or trim your text somehow. Synchronous operations are fine to perform. E.g. If your synchronous operation transforms the input into a different set of characters, consider using a
visualTransformation. What you should avoid is asynchronous operations as they will cause the issues shown above.
Use MutableState to represent TextField state
Avoid using reactive streams (e.g.
StateFlow) to represent your
TextField state, as these structures introduce asynchronous delays. Prefer to use
This solution requires deeper coroutines knowledge and might lead to issues:
* Since the collection is synchronous, it’s possible the UI is in a not-ready-to-operate-on state when it happens.
* Interferes with Compose’s threading and rendering phases, amongst other things, because it assumes that recomposition happens on the Main thread.
Where to define the state
TextField state requires business logic validations as you type, it is correct to hoist the state to your
ViewModel. If it doesn’t, you can use Composables or a state holder class as the source of truth.
As a general rule, you should place your state as low as it can be, while still being properly owned, which generally means closer to where it is used. For more information about state in Compose, you can check our guide.
In solving this issue, it is not as important where you hoist
TextField state, but how you store it.
Applying best practices in your app
Let’s implement both a sync and an asynchronous validation to our
TextField state, considering these best practices.
Starting with an asynchronous validation, we want to show an error message below the
TextField if the username is not valid to use, and this validation is performed server-side. It will look like this in our UI:
onValueChange is called, we will update
TextField immediately by calling the update method. And then, the
ViewModel will schedule an async check based on the value that just changed.
ViewModel, we define two state variables: a username variable for the
TextField state as
MutableState, and userNameHasError as a
StateFlow, reactively calculated whenever the username updates.
Because typing might be faster than obtaining the async call result, we process the events sequentially and use
mapLatest (experimental) to cancel unfinished calls when a newer event comes in to avoid wasting resources or showing incorrect state. We could also add a debounce method (a delay between async calls) for the same reason.
Notice we are collecting the error validation flow with experimental
collectAsStateWithLifecycleAPI, which is the recommended way to collect flows in Compose in Android. To read more about this API you can check this blog post.
Now we want to add a synchronous validation to check if the input contains invalid characters. We can use
derivedStateOf() API, which is synchronous and will trigger the lambda validation every time the username changes.
derivedStateOf() creates a new
State, and composables who read
userNameHasLocalError will recompose when this value changes between
Our complete username implementation field with validation looks like this:
Planning for TextField
At the moment, we’re working on improving the
TextField API and it remains one of our priorities.
The Compose roadmap reflects the work that the team is doing on multiple fronts, in this case Text Editing Improvements and Keyboard input are efforts related to these APIs. So keep an eye out for it and for the release notes coming up in future releases of Compose.