Is this the idiomatic way to do debounce for a tex...
# compose
c
Is this the idiomatic way to do debounce for a text field? I suck with flows and just want to make sure I'm not shooting myself in the foot.
Copy code
class VM : ViewModel() {
    val text = MutableStateFlow("")
    val dbText = text.debounce(2000).distinctUntilChanged().flatMapLatest { (it) }

    private fun queryFromDb(query: String): Flow<String> {
        ...
    }
}
Composable
Copy code
Column {
    val text by viewModel.text.collectAsState()
    val dbText by viewModel.dbText.collectAsState("Empty Result")

    TextField(value = text, onValueChange = { viewModel.text.value = it })
    Text(text = dbText)
}
c
.sample()
is probably more appropriate for this use-case
f
you don't need
distinctUntilChanged
because you use a
StateFlow
and the
flatmapLatest
doesn't do anything
also, this is more architectural, but I would suggest you don't expose the flow to your composable and instead expose a method that will push a new query into your flow,
Copy code
class VM : ViewModel {
    private val text = MutableStateFlow("")

    fun onQueryUpdate(query: String) {
        text.value = query
    }
}
c
Actually he does want to keep
distinctUntilChanged
, because it is applied after
debounce
. If the value of a StateFlow were to change eg from “a” to “b” and back to “a” within the debounce period, this would lead to duplicate sequential emissions downstream.
Colton I can’t tell what you’re trying to do with
flatMapLatest
. The lambda passed should itself be returning a Flow but in your code it seems to be returning a string (you should see an error in your editor)
Do you have a more fleshed out example?
Also maybe a description of exactly what you intend to do. It’s hard to tell from this sample code what your actual use case is
c
All I want to do is let the user type, but then after like 2 seconds of not typing, THEN I kickoff a database request.
c
Yeah, I think just
.sample(2.seconds)
should be all you need. debounce is for waiting a minimum amount of time between emissions, for things like preventing accidental double-clicking.
.sample()
emits 1 value at most every N seconds, and gives you the latest upstream value during the sampliing period. So when it eventually emits downstream to run the DB query, it will be the latest value entered by the user
This example in the Ballast documentation uses the
.sample()
operator for undo/redo functionality on a textfield, which works basically how you need for running the DB query. You can play around with the example to see if it gives you the UI behavior you’re looking for
.sample
will capture the state and run the query as the user types, if they keep typing longer than the sampling period. Otherwise, a simple delay in
.mapLatest
should also give a similar “buffering” effect, but only emit once the user has completely stopped typing
Copy code
test.mapLatest {
    delay(2.seconds)
    queryFromDb()
}
c
Based on Colton’s description, I think
debounce
is actually what he’s looking for. He wants to wait until the user is done typing before querying the db. Whereas
sample
will query while the user is still typing
Colton you probably also want to add a
drop(1)
so that the initial empty string does not get sent as a query
f
Better to add
filterNot { it.isEmpty() }
c
I think I also might want to make use of
*latest
since if theres a long query underway and the user continues to type and a new value comes in, I want to effectively throw away the old value.
c
Yeah Casey’s example of using mapLatest is a good idea
Assuming your dev query supports cancellation
c
Cool cool. I'll make sure of that. We actually changed the implementation that we're doing here and so we're actually now just building out a search bar that will query the network (via retrofit) which I'm fairly certain supports cancellation.
c
Sorry dev query = db query
c
Definitely still a bit confused between debounce, sample, maplatest+delay. I always thought (from rxjava world) that debounce is basically exactly what i want.
c
Debounce is what you want. After the debounce you want to mapLatest.
Debounce will never emit as long as the user is typing within the debounce window. Sample will emit the last value at the END of the sample window. Throttle will emit the first value at the START of the window.
Sorry I’m on mobile atm otherwise I would link you to some example
mapLatest is a different consideration altogether. It’s what gives you support for cancellation, but I wouldn’t use it for delay/timing issues. Use the dedicated Flow operators for that
c
No need to say sorry. that last explanation was perfect!
cheers Chris. Really appreciate it!
c
Np. Forgot to mention, if you ever want throttle behaviour, it’s not available out of the box, you need to provide an implementation. I’ve used this one linked to by Roman. (I think the reason it’s not available is because they’re re-working some core code around Flow timing implementation… I recall reading this somewhere but don’t remember where) https://github.com/Kotlin/kotlinx.coroutines/issues/1446#issuecomment-525261838
I wanted to double check that I wasn’t saying something wrong so I put together this playground: https://pl.kotl.in/tjEHXqM2s One interesting thing is that
sample
appears to drop values in the trailing window. I was surprised to see 10 was not emitted
Ok if I add an additional delay at the end of the flow builder, then 10 is emitted as a sample value. Interesting, TIL
Oh yeah, per the docs for sample:
Copy code
...
 * Note that the latest element is not emitted if it does not fit into the sampling window.
 */
@FlowPreview
public fun <T> Flow<T>.sample(periodMillis: Long): Flow<T> {
a
@Colton Idle just want to flag that, by the look of how ure producing and collecting, you might run into this issue https://medium.com/androiddevelopers/effective-state-management-for-textfield-in-compose-d6e5b070fbe5 because ure using MutableStateFlow as stateholder for the TextField
c
That article strikes me as a bit misleading… the issue isn’t necessarily that the text is managed in a StateFlow, it’s that a suspending function is being called
onValueChange
.
viewModelScope.launch {}
by default runs on
Dispatchers.Main.immediate
, so if you use a
StateFlow
and don’t do anything else in
viewModelScope.launch {}
, everything will work fine.
StateFlows
themselves aren’t bound to any particular CoroutineDispatcher, so if it’s collected on
Dispatchers.Main
in Compose (which it is, by default) and you update it on
Dispatchers.Main
in the ViewModel (which it does, by default), then you shouldn’t have the kind of synchronization issue the article is talking about. You’d have the exact same problem with text synchronization if you used the same buggy version of
viewModel.updateUsername()
with
val _username: MutableState<String>
instead of
val _username: MutableStateFlow<String>
. The article makes it seem like the
StateFlow
is the problem, but it’s the async call to
userRepository.isUsernameAvailable
that’s the real problem. If you set the value in the ViewModel immediately, and then launch a “fire-and-forget” in response to the changed text, you will be fine
a
Copy code
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.
i just needed to flag it, in case you forget to do this and run into these issues. also the intention of the article was not to be misleading, but to really really really recommend you away from stateflow/async reactive streams when working with textfield
c
I understand what the article is trying to say, but I think I just disagree with the conclusion. I don’t really think that this
Copy code
var username by mutableStateOf("")
val userNameHasError: StateFlow<Boolean> =
    snapshotFlow { username }
        .mapLatest { signUpRepository.isUsernameAvailable(it) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = false
        )

fun updateUsername(input: String) {
    username = input
}
is the “right” way to manage text, when this is just as correct, and is easier to write and understand
Copy code
private val _username = MutableStateFlow("")
private val _userNameHasError = MutableStateFlow(false)

fun updateUsername(input: String) {
   viewModelScope.launch {
       _username.value = input
       
       val isUsernameAvailable = userRepository.isUsernameAvailable(input)
       _userNameHasError = !isUsernameAvailable
   }
}
And of course, you could move the
userRepository.isUsernameAvailable
into a debounced query like the OP posted just fine, it won’t become “more correct” to back OPs
text
with a
State
instead of
StateFlow
f
this solution of yours relies on the coroutine running on the
immediate
dispatcher so that the update to the username, in the textfield, is synchronous so it should work, but at the same time it depends on an implementation detail of the
viewmodelScope
c
thanks for the article @Ale Stamato I haven't seen that before! i'll take a read definitely. love the conversation it spurred here!
1325 Views