Hi :android-wave: , I am trying to use the new sta...
# compose
a
Hi 👋 , I am trying to use the new state based TextField. I have a single StateFlow exposed from the ViewModel that contains the state of the screen. I am following the example shared in the docs here - https://developer.android.com/develop/ui/compose/text/migrate-state-based#conforming-approach. The problem with this approach is, the initial state emitted by the ViewModel would have empty string as the default value for the text field. The actual initial state of the Text Field would be emitted after the required data is fetched from local database. But, this code does not update the
TextFieldState
with the subsequent values emitted from the viewmodel.
Copy code
val usernameState = rememberTextFieldState(initialUiState.username)
LaunchedEffect(usernameState) {
  snapshotFlow { usernameState.text.toString() }.collectLatest {
    loginViewModel.updateUsername(it)
  }
}
Could anyone please help share how to solve this?
e
Could you hold off on displaying the TextField at all until the database fetch is complete? That also helps avoid flashing the blank text box while the fetch is ongoing.
a
The issue is not showing an blank textfield while the data is loading. The text field has a loading state that shows a shimmer. The issue is that the
TextFieldState
is not updated once the data is fetched as it is remembered.
e
Right, so my suggestion is to put the text field and the
rememberTextFieldState
inside an
if
block so that neither gets rendered until the data is fetched.
w
That snippet you linked is specifically designed for cases where the ViewModel doesn't drive the text state. It sounds like you do want that, so you should just hoist the state fully into the VM.
a
Hi @Winson Chiu, The shared docs mentions for ViewModel with the text field state stored as
String
instead of
TextFieldState
. I am following the same approach as my current architecture has a single StateFlow from the ViewModel to the UI layer.
w
Look at the bullet points under the snippet:
Your ViewModel will still have the latest values from UI, but its uiState: StateFlow<UiState> won't be driving the TextFields.
a
Hi @Eddie, The initial text not loading as expected is fixed with your suggestion. But, the TextField value can not be controlled by the ViewModel anymore with this new
TextFieldState
.
e
Yes, as Winson alluded to, this pattern treats ViewModel as a simple value holder.
a
Oh no! In that case, this requires changing lot of things to work with the new
TextFieldState
kodee sad .
I missed to notice that line. So, how would I update the
TextFieldState
based on some events with this 😕 ?
w
Can you change your UiState to just hold the state instead? That's easiest, although you end up having a lot of events flowing through the UiState since it has to sync every single change from the UI, including cursor position. You could also use some key to invalidate the remembered state and re-initialize it, but that feels very complicated.
a
You could also use some key to invalidate the remembered state and re-initialize it, but that feels very complicated.
Already working on this, not able to make it work completely.
Doesn't this new
TextFieldState
break UDF 🤔 ? https://developer.android.com/develop/ui/compose/architecture
TextField
component is used as the example here to showcase UDF. What is the reasoning behind storing
TextFieldState
in the ViewModel if it is a two way communication data structure?
Created an issue for tracking and further discussion - https://issuetracker.google.com/issues/442489271
w
Eh, I don't really see TextFieldState as breaking UDF. It just abstracts the onValueChange update loop away from app code. It's still expected that the VM only send updates by changing the state object.
a
VM sends updates by changing the state object instead of handling it as an event. Shouldn't that be considered as breaking UDF?
Also, in this case, Composable also sends events to the VM by updating the same state object.
w
Right, but UDF supports that. The VM should not send events down, but the composable should send events up.
a
Composable should ideally sends events as method calls or callback. If that is not case, why do have to create immutable objects like this?
Copy code
private val _uiState = mutableStateOf<UiState>(UiState.SignedOut)
    val uiState: State<UiState>
        get() = _uiState
If the uiState is Mutable State, composable can directly update it from the UI. In case of
TextFieldState
, it seems similar to mutable state rather than immutable state, to enable two way data communication.
https://developer.android.com/develop/ui/compose/architecture#architecture-events
Copy code
The UI layer should never change state outside of an event handler because this can introduce inconsistencies and bugs in your application.

Prefer passing immutable values for state and event handler lambdas.
You ensure that your UI doesn't change the value of the state directly.
w
You can do it that way, although I prefer just exposing a mutable property directly. If you have a state field that you're going to mutate via a
fun setState
exposed in the VM which Compose calls anyways, then making the state itself mutable is just an abstraction of that update.
a
I tried this suggestion.
Can you change your UiState to just hold the state instead?
This is working for the basic use-cases I have checked so far. This also requires using
snapshotFlow
to collect the latest text as the text update now no longer is via a setter method.
Copy code
coroutineScope.launch {
            snapshotFlow { title.text.toString() }.collectLatest {
                // Use the text for validation and other use-cases.
            }
        }
z
TextFieldState
is actually more compatible with UDF since it lets you fully own the source of truth for what’s in the field. If you want a callback for when text changes for things like validation, see
InputTransformation
.
a
Hi @Zach Klippenstein (he/him) [MOD], Is
InputTransformation
intended to be used as a replacement for
onValueChange
for validation? The use case is to check if the screen CTA can be enabled based on the value entered in the Text Field. There’s no input transformation in this scenario, so IMHO, using InputTransformation would be confusing.
w
Is the calculation expensive? You can just read the value from the state to set enabled on the button in Compose. And just rely on the snapshot updating to keep that check up to date. Could slap
derivedStateOf
on there if you want to avoid recomposing the button.
a
Is the calculation expensive?
To some extent yes, it may involve database data fetching, datastore data fetching or file reading. (Some IO)
z
If you need to reject input then InputTransformation is the best choice. If you’re just showing an error message or something then observing the state is fine
👍 1
a
This is the current setup I have.
ScreenUIState
Copy code
@Stable
internal data class ScreenUIState(
  val titleTextFieldState: TextFieldState = TextFieldState(),
  // Other data
)
ViewModel
Copy code
private val _uiState: MutableStateFlow<ScreenUIState> = MutableStateFlow(ScreenUIState())
internal val uiState: StateFlow<ScreenUIState> = _uiState.asStateFlow()

fun initViewModel() {
  coroutineScope.launch { snapshotFlow { title.text.toString() }.collectLatest {
    // Validate and update ui state
  }}
}
It seems to work for the use-cases I have tested so far. Please do share if this can be improved. I would ideally like to: 1. emit a single UI State data class from the ViewModel. 2. And if possible, store the text field data as only string in the ViewModel. (Not able to do this so far)
d
Doesn't putting the TextFieldState inside a StateFlow reintroduce one of the original issues BTF2 was trying to solve - lag between user input and screen updates?
w
Yeah, I guess it's not clear to me why you want to use the new TextFieldState variant in this case. If you just want a string, just keep using the old one.
z
Doesn't putting the TextFieldState inside a StateFlow reintroduce one of the original issues BTF2 was trying to solve - lag between user input and screen updates?
It should be the same instance of TFS in every state (in most cases), so no.
a
One issue I encountered so far when adding the
TextFieldState
to the UI State data class and emit it using StateFlow is that the StateFlow does not emit an event when only the
TextFieldState
text content changes (expected as the instance of the
TextFieldState
remains the same). This works for the production code. But, testing is now a bit more confusing.
Copy code
@Test
fun clearTitle_shouldClearText() = runTest {
  val updatedTitle = "testTitle"
  screenViewModel.uiState.test {
    awaitItem().isLoading.shouldBeFalse()
    screenViewModel.updateTitle(updatedTitle)
    val result = awaitItem()
    result.titleTextFieldState.text.toString().shouldBe(updatedTitle)

    screenViewModel.clearTitle()
    result.titleTextFieldState.text.toString().shouldBeEmpty() // Can not use awaitItem() here as there is no new emission in the StateFlow, we have to use the previous emitted item
  }
}
h
Thanks for starting this discussion as we were expecting that this would be the most heated topic when TextFieldState was being designed. So the first thing I would say is that the internal "editor" state that a TextField maintains is unlike many other UI components. Even though in many cases it may seem like just a plain old string, the internals are far from that. The first API of TextFields in Compose famously used the (value, onValueChange) loop. This looked super-compatible with the UDF principle. ViewModel would hold the UiState and textfield contents would just be a string pushed by a state flow, then
onValueChange
would update the value that’s held in this StateFlow. However, the problems start when we take a closer look at this tight seeming loop (value, onValueChange). Sidenote; I can talk about the async nature of StateFlow and how it causes certain problems (https://medium.com/androiddevelopers/effective-state-management-for-textfield-in-compose-d6e5b070fbe5) here but let’s skip that. The fundamental handicap of callback based state keeping in Compose is that compose runs in phases. If a composable hoists its main state, as we used to in
TextField(value)
, then Composition is your only timing to instruct the Composable to update its state and sync with the value coming from your ViewModel or anywhere else. In other words, if you would like to filter the input coming from the user, there is a gap between the user's input and the next composition cycle where things stand a little vague. An example, you want to have a numeric filter for your phone number field, so you want to prevent people from typing letters through their hardware keyboard; • User types
a
onValueChange
triggers with
a
included • Currently the internal state of TextField has
a
in it. • Your ViewModel removes the
a
, passes along the filtered
value
• TextField updates its internal state • Currently the internal state of TextField has no
a
in it. Now think about what happens if there is concurrency involved (StateFlow) and the UI update lacks one frame behind. Well this shouldn’t be an all new problem. Many UI components work the same way and have the same interception requirements. What about a checkbox? The same problem can definitely be said about a checkbox too, what if we stop its state progression from checked to unchecked and concurrency messes up the frame timing? In that case, checkbox has the peace of mind that it doesn’t report its state to multiple clients. When the user clicks on it; • Checkbox reports a change from unchecked to checked • Checkbox doesn’t update its internal state. It is awaiting the hoisted state to change • Your state holder either accepts the change, or blocks it • Next composition cycle decides what to render. Alright, then why does TextField update its internal state immediately before awaiting the state holder to decide what should happen? The answer is IME. InputMethodEditor or also known as Software Keyboard is always in communication with the editor/TextField. This communication happens very sequentially and does not respect concurrency at all. IME tells something to the Editor and expects its result immediately, so that it can manage its own state predictably. All in all, IME is a separate process and it can only communicate with your editor through messaging (ask -> receive answer). Let’s take a look at the filtering problem with now
IME
in mind. • User types
a
• IME sends a command
UserTyped(a)
• TextField updates its internal state with
a
. ◦ TextField at this point cannot ask the user about the next value. That will happen during the next composition cycle. • IME asks back what happened? TextField replies the current state has
a
in it. •
onValueChange
triggers with
a
included. • Your ViewModel removes the
a
, passes along the filtered
value
. • TextField updates its internal state. • IME should be notified that the editor state has changed outside of its knowledge. ◦
ResetIME
is called. • At this point IME may flicker, may lose its composing range, and any other side effect that comes with
ResetIME
call. The crucial part here is that
IME
expects an answer immediately from the TextField at any time. I can write even more and maybe this should be a blog post of its own but basically the
(value, onValueChange)
design is not a sustainable approach to a text editor state in Compose. We needed a more tightly closed loop like the one that is managed internally by
TextFieldState
. I realize that it sways away from the singular UiState approach but it definitely doesn’t break UDF. The good news is that you can still opt-in to
value, onValueChange
(https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldValueSample.kt;l=39) if you know what you are doing and extensively tested your application with many different types of Software Keyboards (gboard, swiftkey, samsung, etc.). Ps; I simply wanted to highlight one of the many challenges behind designing a TextField API. There are even more scenarios where
value, onValueChange
breaks the expected behavior like granular editing.
👍 1
Also I agree that the current migration part of the documentation is lacking on filtering use case. If you were doing your filtering in ViewModel, try to move it into
InputTransformation
. If that looks infeasible, please share your case so we can take a look at it together.
a
Hi @Halil Ozercan, Thanks for the detailed explanation thank you color. Could you please share if the long-term plan is to move to
TextFieldState
from the existing callback-based component? If yes, what’s the expectation for apps using an architecture that emits a single StateFlow from the ViewModel? Will there be better support for this, or will that architecture not work well with
TextFieldState
? https://developer.android.com/develop/ui/compose/text/migrate-state-based#viewmodel-stateflow
h
• Yes, long term plan is definitely moving to
TextFieldState
and eventually deprecating
value, onValueChange
• I believe the expectation is explained in the migration guide that you linked. I'm afraid there won't be a single magical solution since the problem we are trying to solve has many edges. However we will try to improve the guide with more examples and advice, encompassing all the different use cases that are brough up by the developers. • Again I believe that architecture can work with the new
TextFieldState
but there are multiple ways to make them work together. It is going to depend on your specific setup and how you are using TextField.
a
I am migrating my use cases gradually. The production code migration has been successful so far. The main issue I faced is related to testing: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1757153373702089?thread_ts=1756823521.875819&amp;cid=CJLTWPH7S. To simplify the test case: • When the screen is opened ⇒ emit a
ScreenUiState
data class with required data. The initial state of
TextFieldState
would be an empty string. • Once data is fetched (from database, network, file, shared preference, data store, etc.) ⇒ emit an updated
ScreenUiState
with the new state of
TextFieldState
. The issue is that the second UI state emission isn’t triggered if the only difference is the text in
TextFieldState
. This is expected since StateFlow emits only when the underlying object instance changes. Could you please share if there are any recommendations for this test case?
h
you don't need to emit a new state to signal a text change to the UI. You can just change the text inside
TextFieldState
using
TextFieldState.edit { }
and your TextField should update automatically. For testing, you can simply check the
TextFieldState.text
value to verify that text change happened as expected.
But it will require a more involved setup if you want to test your
TextFieldState.text
change using a
snapshotFlow
. We are preparing more documentation to cover the testing strategies for
TextFieldState
and in general the new TextField
w
you don't need to emit a new state
I think the idea is that if you're going to follow a single UiState data class, you want the emissions of the stream to exactly mirror the updates to the UI. I'm exploring the different mechanisms to model state in our internal recommended architecture, and it seems hard to justify the single UiState model when you violate that principle. It also seems overly restrictive when just having multiple sibling state fields is easier to manage. No massive combine mechanism to get to that single UiState.