Compose Text Fields
Fixing State Races
The main solution is to mirror the state that will be displayed in the field, and ensure that updates to the real data happen in the proper order, or all at once on a user action (such as pressing a "save" button or navigating back from the edit page)
-
Mirror state during async processing
- Local state updates the text fields
- As fields change, inform caller to update the external state
- External state is updated in database
- Only works if any data translation locally and externally is the same
-
Mirrored state with final updates made on user action
- Local state updates the text field
- External state is not changed while user types
- User pressing "save" or navigating back triggers
- Inform caller of change
- External data is updated and saved in database
Let's look at some examples
Saving data while the user is typing
I like this approach because all data being edited is kept in sync with all parts of the UI that use the data.
For example, suppose we have a list/detail setup; a list of items is on the left side of the screen, and the selected item is displayed on the right side of the screen. Some data will be shared - perhaps the name of an item is displayed in the list, but can be edited in the detail. As the user changes the name, it might be nice to change it in the list.
Note
This approach can have some interesting consequences when the list is sorted by the property being edited - you may need to perform some extra work to ensure the item being edited stays visible in the list.
One way to perform this type of processing:
- Pass in an id for the item to the edit screen
- Fetch the actual object in a LaunchedEffect, keyed by the id. It won't be re-fetched unless the id changes. This allows us to get an initial copy of the object that won't change while the user makes changes.
- Either
- Use this object and its properties as the text fields' state, or
- Extract each property into a separate
State
as the text field state (initialized to the fetched-object properties) - As the user types
- Update the local state, triggering recomposition of the text fields, AND
- Tell the caller that the value has changed
- The caller can update the database, and it doesn't trigger recomposition, as its id hasn't changed. I recommend that you debounce this update so if the user types quickly, the database will only update when they pause typing
Code for this might look like
class PersonViewModel(
private val personRepository: PersonRepository
): ViewModel() {
val personFlow = personRepository.personFlow
suspend fun fetchPerson(id: String): Person? =
personRepository.getPersonById(id)
// manual debouncing
// (this implementation assumes all updatePerson calls come from the same thread)
// launch a coroutine and hold a pointer to it
// delay a certain amount of time
// perform the update
// clear the pointer (not really necessary)
// next time we try to run, before launching the coroutine
// if we have a pointer to the last coroutine run, cancel it
// if it's already finished, cancel doesn't do anything
// it it's still running, we abandon the work and start again
// This has the effect of only running the work if there's a 500ms gap between calls
// This is great for running work on each key press or fast mouse clicks
private var updateJob: Job? = null
fun updatePerson(person: Person) {
updateJob?.cancel()
updateJob = viewModelScope.launch(Dispatchers.IO) {
delay(500)
personRepository.update(person)
updateJob = null
}
}
}
@Composable
fun PersonEditScreen(
id: String?,
onPersonChange: (Person) -> Unit,
fetchPerson: suspend (String) -> Person?,
) {
var person by remember { mutableStateOf<Person?>(null) }
LaunchedEffect(key1 = id) {
person = id?.let { fetchPerson(it) }
}
person?.let { fetchedPerson ->
...
OutlinedTextField(
value = fetchedPerson.name,
label = { Text("Name")},
onValueChange = {
val newPerson = fetchedPerson.copy(name = it)
person = newPerson
onPersonChange(newPerson)
},
)
}
}
This looks like
In this video, the "Person name in DB" field is separate from the person edit UI. It collects a Flow
from the database, showing the name of the person we stored.
I type my last name fast enough that there is less than 500ms between each keystroke. Once I pause, the database is updated and my last name appears. I then delete several characters, pausing once in a while, until the field is blank. The database only updates during the pauses. I then type "Hello", pause, then " there", pause. Again, the text only updates in the database when I pause.
A safer way to debounce is to use the built-in (experimental) debounce()
function in the view model. Here we use a MutableStateFlow
as a "person update queue", debouncing the flow itself. Note that debounce()
creates a new flow chained to the original flow.
We collect the flow in a viewModelScope
-launched coroutine, which is canceled when the view model is dismissed.
The update call just becomes placing the person to update in the queue. The debounce()
flow waits a bit before emitting the item, and will skip it if a new item comes in before the timeout.
@OptIn(FlowPreview::class)
class PersonViewModel(
): ViewModel() {
private val personRepository: PersonRepository = PersonRepository()
val personFlow = personRepository.personFlow
suspend fun fetchPerson(id: String): Person? =
personRepository.getPersonById(id)
// using a debounced flow as a person-update queue
private val personUpdateFlow = MutableStateFlow<Person?>(null)
init {
viewModelScope.launch(Dispatchers.IO) {
personUpdateFlow.debounce(500).collect { person ->
person?.let { personRepository.update(it) }
}
}
}
fun updatePerson(person: Person) {
personUpdateFlow.value = person
}
}
The behavior is the same as the previous example.
Updates made on user "save" or "back"
- each field has its own state
- initialize to properties of passed-in object
- user presses explicit "save" button or navigates back
- notify caller of all changes and exit
@Composable
fun PersonEditScreen(
id: String?,
onPersonChange: (Person) -> Unit,
fetchPerson: suspend (String) -> Person?,
onExit: () -> Unit,
) {
var person by remember { mutableStateOf<Person?>(null) }
LaunchedEffect(key1 = id) {
person = id?.let { fetchPerson(it) }
}
person?.let { fetchedPerson ->
...
OutlinedTextField(
value = fetchedPerson.name,
label = { Text("Name")},
onValueChange = {
person = fetchedPerson.copy(name = it)
},
)
...
Button(
onClick = {
person?.let { onPersonChange(it) }
onExit()
}
) { Text("Save") }
} ?: Text(text = "Loading")
}
Note in this version of the PersonEditScreen
we don't call onPersonChange
when values of the TextField
change; we only update the local Person
. When the save button is pressed, we make the call to onPersonChanged
and call onExit()
to inform the caller we can navigate out.
This looks like the following (without the onExit()
call so we can see multiple updates). Note that the Database isn't updated unless the save button is pressed. I've disabled debouncing in this example, though you may want to use it as the user could click many times on the button very quickly causing lots of saves (all with the same data).
We can do similar processing using a BackHandler
(to always save when the user navigates back) instead of the save button:
@Composable
fun PersonEditScreen(
id: String?,
onPersonChange: (Person) -> Unit,
fetchPerson: suspend (String) -> Person?,
onExit: () -> Unit,
) {
var person by remember { mutableStateOf<Person?>(null) }
LaunchedEffect(key1 = id) {
person = id?.let { fetchPerson(it) }
}
BackHandler {
person?.let { onPersonChange(it) }
onExit()
}
person?.let { fetchedPerson ->
...
OutlinedTextField(
value = fetchedPerson.name,
label = { Text("Name")},
onValueChange = {
person = fetchedPerson.copy(name = it)
},
)
} ?: Text(text = "Loading")
}