Open question… do you prefer to debounce user inpu...
# compose
c
Open question… do you prefer to debounce user input (edit: I mean text input by the keyboard) in a composable (with LaunchedEffect) or in your VM? (Or another way?) Do you have an opinion on best practice here?
f
ideally a composable is just a map from state to UI, so it should not handle logic like debouncing. I would do that in the viewmodel instead
6
🙏 1
i
Debouncing should be considered a last resort when it comes to making your click handling idempotent - usually there is a better way depending on exactly what you are doing (e.g., the Navigation Component lets you use the Lifecycle to know if you've already started navigating somewhere or a refresh operation should no-op if a refresh is already in progress). What exactly are you trying to debounce?
m
I believe probable the VM or your state handler is the best place. But if by any reason you cant here is a snippet we use and we havent faced many issues so far
Copy code
LaunchedEffect(textState) {
    snapshotFlow { textState.value }.drop(1).debounce(1000).collect {
        state.onChanged(it)
    }
}
c
I prefer using the MVI pattern, encapsulating all user-events into discrete “intent” objects, then buffering those inputs through a Channel to process them one-by-one. By reading and processing those inputs sequentially, one-at-a-time, you can intelligently handle these kinds of situations rather than a general “debounce” strategy and hoping it works well enough. For example, if a button is pressed to submit an API call, and that button sends 2 click events, you can do
channel.receiveAsFlow().collect { }
to wait for the first click to be completed before starting to process the second one. You can update a value in a StateFlow to denote success or failure, which the second one can then use to determine if the click was already handled or not. Alternatively,
channel.receiveAsFlow().collectLatest { }
will cancel the first input so that the second one can start processing. There’s a lot of other power and flexibility you get for handling these kinds of different use-cases when thinking about handling code in this manner. Here’s an example of how a VM might look doing this pattern, to help you understand the general idea:
Copy code
public class MyViewModel(
    private val coroutineScope: CoroutineScope,
    private val api: MyApi,
) {

    private val _state = MutableStateFlow(State())
    private val channel = Channel<Inputs>(Channel.BUFFERED, BufferOverflow.SUSPEND)

    init {
        coroutineScope.launch {
            channel
                .receiveAsFlow()
                .collect {
                    when (it) {
                        is Inputs.Submit -> {
                            if (_state.value.result != null) {
                                // ignore the click, we've already handled it
                                return@collect
                            }

                            val result = api.submit()
                            _state.update { it.copy(result = result) }
                        }
                    }
                }
        }
    }

    public fun submit() {
        channel.trySend(Inputs.Submit)
    }

    public data class State(
        val result: ApiResult? = null
    )

    private sealed interface Inputs {
        public object Submit : Inputs
    }
}
🤔 1
But, of course, you probably want to use a dedicated MVI library, like my own Ballast library, to make this pattern easier to implement in your app, and keep the whole “state machine” working much more safely than the basic snippet I put above (guaranteeing against race conditions, corrupted state, and things like that)
f
I didn't read the question as debuncing click handling, rather general debouncing (like triggering a search on a query if the user has stopped typing), in which case you actually would have to do a debouncing rather than relying on the MVI framework
c
I must apologize for the vague question. Indeed @Francesc is right, I meant debouncing text input, e.g. to trigger a search query. It makes sense to me that this behaviour can be colocated with the TextField. (Conversely, I would probably not debounce clicks in a composable, e.g. for navigation, or say, to load some data. I would hoist this concern to a higher level.)
f
if you were to place the debouncing logic in the composable that would make it more specialized and thus less reusable, while having the logic in the viewmodel would allow you to reuse the input composable elsewhere
c
Ok this is kind of getting closer to what I’m wondering. I think this could just be a matter of tradeoffs. If you were designing a reusable search input, debounce behaviour might be a nice, reusable behaviour. (Certainly it would not be for something like an input for the user’s name)
Alternately you could just build a utility like this
Copy code
@Composable
fun <T> Debounce(
  debounceMs: Long,
  initialValue: T,
  onChange: (T) -> Unit,
  content: @Composable (onChange: (T) -> Unit) -> Unit,
) {
  var value by remember { mutableStateOf(initialValue) }
  LaunchedEffect(initialValue) {
    snapshotFlow { value }
      .drop(1)
      .debounce(debounceMs)
      .collect(onChange)
  }
  content { value = it }
}
e
I would say, let updates to the state be synchronous (always), debounce to search should be done asynchronously at the business logic level, You can use snapshotFlow here to observe the text field value and debounce it in some business layer coroutine.
153 Views