DEV Community

Baharudin Maulana
Baharudin Maulana

Posted on

Why I Stopped Using Callbacks in Android and Switched to Flow + ViewModel Events

I'll be honest with you I used to think callbacks were fine.
It works, right? You pass a function, something calls it back, you handle the result. Simple. But after months maintaining a production POS Android app with complex payment flows, I started dreading every bug report. Not because the bugs were hard but because tracing them meant diving into a pyramid of nested callbacks at 11pm, wondering where the lifecycle even was at that point.
That's when I said: enough.
I migrated to Flow + ViewModel Events and I haven't looked back since.

The Problem With Callbacks
You've seen this before:

paymentManager.startPayment(amount, object : PaymentCallback {
    override fun onSuccess(result: PaymentResult) {
        printReceipt(result, object : PrintCallback {
            override fun onPrintDone() {
                saveTransaction(object : SaveCallback {
                    override fun onSaved() {
                        // Are you still reading this? 😅
                    }
                    override fun onError(e: Exception) { ... }
                })
            }
            override fun onError(e: Exception) { ... }
        })
    }
    override fun onError(e: Exception) { ... }
})
Enter fullscreen mode Exit fullscreen mode

Three core problems:

  1. Lifecycle blindness callbacks fire even after your Activity/Fragment is destroyed. Hello, IllegalStateException.
  2. Race conditions two async callbacks hitting the UI thread at once? Good luck debugging that.
  3. Untestable mess mocking nested callback interfaces is painful boilerplate nobody wants to write.

The Pattern: Flow + ViewModel Events
The idea is simple:

ViewModel emits events via SharedFlow → UI collects inside repeatOnLifecycle

  1. Model events as a sealed class
sealed class PaymentEvent {
    data class Success(val result: PaymentResult) : PaymentEvent()
    data class Error(val message: String?) : PaymentEvent()
    object NavigateToReceipt : PaymentEvent()
    data class ShowDialog(val title: String, val message: String) : PaymentEvent()
}
Enter fullscreen mode Exit fullscreen mode
  1. Emit from ViewModel
class PaymentViewModel(
    private val paymentUseCase: ProcessPaymentUseCase
) : ViewModel() {

    private val _uiEvent = MutableSharedFlow<PaymentEvent>()
    val uiEvent = _uiEvent.asSharedFlow()

    private val _uiState = MutableStateFlow(PaymentUiState())
    val uiState = _uiState.asStateFlow()

    fun processPayment(amount: Long) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            paymentUseCase(amount)
                .onSuccess { result ->
                    _uiState.update { it.copy(isLoading = false) }
                    _uiEvent.emit(PaymentEvent.Success(result))
                    _uiEvent.emit(PaymentEvent.NavigateToReceipt)
                }
                .onFailure { error ->
                    _uiState.update { it.copy(isLoading = false) }
                    _uiEvent.emit(PaymentEvent.Error(error.message))
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Collect in Fragment
viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiEvent.collect { event ->
            when (event) {
                is PaymentEvent.Success -> handleSuccess(event.result)
                is PaymentEvent.Error -> showErrorSnackbar(event.message)
                is PaymentEvent.NavigateToReceipt -> navigateToReceipt()
                is PaymentEvent.ShowDialog -> showInfoDialog(event.title, event.message)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4 Gotchas to Avoid

  1. Never collect outside repeatOnLifecycle your collector will stay alive in the background and events will fire when views are null.
  2. Don't set replay = 1 on event flows navigation events will re-trigger after screen rotation. Very not fun.
  3. Don't use tryEmit it silently fails when the buffer is full. Always use emit inside a coroutine.
  4. In Fragments, always use viewLifecycleOwner.lifecycleScope, not lifecycleScope. They are not the same.

Result
After migrating our production POS app:

Zero lifecycle crashes from the event handling layer
Faster debugging every event traces back to a clear ViewModel method
Cleaner tests no more mock callback interfaces
New devs understand the screen flow just from reading the sealed class

Wrapping Up

Sealed classes for events
SharedFlow (no replay) for one-time events
StateFlow for persistent UI state
Always repeatOnLifecycle
Always viewLifecycleOwner in Fragments

If you want a production-ready boilerplate with this pattern already baked in — Clean Architecture, multi-module, offline-first Room + Paging 3, Koin — check out my KMP Production Starter PRO on Gumroad.
Follow for more Android and KMP deep dives from real production apps.

Top comments (0)