Effective state management for TextField in Compose

Alejandra Stamato
Android Developers
8 min readSep 14, 2022

--

TL;DR

  • To prevent synchronization issues and unexpected behaviors:
    Avoid delays/asynchronous behaviors between typing and updating TextField state.
    – Avoid holding TextField state using a reactive stream (e.g. collecting from StateFlow using the default dispatcher)
    Define TextField state variables with Compose APIs like MutableState<String>.
  • Hoist TextField state to the ViewModel when needed e.g. applying business validations to the TextField 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:

Implementing a sign up screen with two TextFields

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:

  1. Stores the current value that is displayed and we pass to TextField value parameter.
  2. Gets updated whenever the user inputs new text in the TextField’s onValueChange 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:

TextField incorrect behavior as we try to edit username jane@mail.com into jane.surname@mail.com

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:

Interactions between TextField states
  1. An event comes in from the keyboard (the word “hello” is typed), and is forwarded to the internal controller.
  2. The internal controller receives this update “hello” , forwards to the state holder.
  3. The state holder is updated with “hello” content, which updates the UI, and notifies the internal controller that it has received the update.
  4. The internal controller notifies the keyboard.
  5. 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.

TextField internal state is overridden to be ‘hello’ instead of ‘hello world’

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.

TextField inconsistencies after each asynchronous process resumes

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:

Displays an error because “username1” is already in use

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:

Username field implementation with a sync and async error

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.

--

--

Alejandra Stamato
Android Developers

Lead Android Developer | Ex Android DevRel @ Google | 🇦🇷 in London🏴󠁧󠁢󠁥󠁮󠁧󠁿