Effective state management for TextField in Compose
TL;DR
- To prevent synchronization issues and unexpected behaviors:
– Avoid delays/asynchronous behaviors between typing and updatingTextField
state.
– Avoid holdingTextField
state using a reactive stream (e.g. collecting fromStateFlow
using the default dispatcher)
– DefineTextField
state variables with Compose APIs likeMutableState<String>
. - Hoist
TextField
state to theViewModel
when needed e.g. applying business validations to theTextField
content. - November 2023 update: we’re currently working on the next generation of
TextField
APIs to address this and other issues. You’ll hear more about what we’re building throughout 2023/24. Look out for future releases.
— BasicTextField2: A TextField of Dreams [1/2] — blog post on the new BasicTextField2 API.
— BasicTextField2: A TextField of Dreams [2/2] blog post on the new BasicTextField2 API.
— DroidCon LON 23 recorded talk on BasicTextField2 API.
— DroidCon SFO 23 talk on BasicTextField2 API + slides.
— Check out this stream on the future of TextField in Compose.
Let’s say we have to implement a sign up screen in a Jetpack Compose app, and we receive the following design for it:
We have two TextField
composables and a Button
.
Let’s start with the top TextField
which is the username field.
To implement a TextField
in Compose, we need to define a state variable that:
- Stores the current value that is displayed and we pass to
TextField
value parameter. - Gets updated whenever the user inputs new text in the
TextField
’sonValueChange
callback.
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 ViewModel
, 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 username
.
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.
The issue
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?
TextField’s internals
At this point in time (Compose UI 1.4.3), 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
MutableStateFlow
variable. - 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
TextField
at 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
When 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 MutableState
instead:
If you would still rather use
StateFlow
to store state, make sure you’re collecting from the flow using the immediate dispatcher as opposed to the default dispatcher.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
If your 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:
When 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.
In the 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.
The snapshotFlow
API transforms Compose State
into a flow so that we can perform asynchronous (suspend) operations on each value.
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
collectAsStateWithLifecycle
API, which is the recommended way to collect flows in Compose in Android. To read more about this API you can check the Consuming flows safely in Jetpack Compose 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 true
and false
.
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.
Thank you to Sean McQuillan and Zach Klippenstein on the Jetpack Compose Text team and Manuel Vicente Vivo and Florina Muntenescu on the DevRel team for their suggestions and thorough reviews.