Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 014 / 100

Kotlin Flow explained: a practical Android & Kotlin guide

Streams of values over time.

Expona Admin6 min read

Kotlin Flow explained: a practical Android & Kotlin guide

If you've been building Android apps or backend services in Kotlin, you've likely encountered situations where you need to emit and consume multiple values over time—think user input, sensor readings, or API polling. Kotlin Flow is the standard reactive tool for handling these asynchronous streams, and once you understand how it works, you'll write cleaner, more testable code. This article walks through cold flows, StateFlow, SharedFlow, and the operators that make them powerful.

Understanding cold flows

A cold flow is lazy by design. Nothing happens until someone collects from it. Think of it like a sequence that generates values on-demand, except it does so asynchronously and can emit multiple values to multiple collectors independently.

Cold flows are perfect when you want each collector to get its own independent stream. In an e-commerce app, you might have a flow that emits product data—each time a screen collects from it, it runs the whole pipeline from scratch.

kotlin
fun getProductUpdates(productId: String) = flow {
    while (true) {
        val product = api.fetchProduct(productId)
        emit(product)
        delay(5000) // Poll every 5 seconds
    }
}

// In your ViewModel or repository
viewModelScope.launch {
    getProductUpdates("SKU123").collect { product ->
        updateUI(product)
    }
}

Notice that the flow body only runs when collect is called. If you never call collect, the API is never hit. This is crucial for resource efficiency.

You can also transform flows inline using operators like map and filter:

kotlin
fun getDiscountedProducts(productIds: List) = flow {
    productIds.forEach { id ->
        emit(api.fetchProduct(id))
    }
}.filter { it.discount > 0 }
    .map { it.copy(displayPrice = it.price * (1 - it.discount)) }

Each collector of this flow independently goes through the entire pipeline. Cold flows scale well for one-to-one scenarios but become wasteful when many collectors need the same data.

StateFlow for UI state

StateFlow is a hot flow variant that always holds a value. It's designed for representing state—the current UI state, the logged-in user, the current filter selection. Every new collector immediately receives the latest value, even if collection happens long after emission.

In a trading or CRM application, you might use StateFlow to hold the current user session or the active deal being edited:

kotlin
class UserViewModel : ViewModel() {
    private val _user = MutableStateFlow(null)
    val user: StateFlow = _user.asStateFlow()

    fun login(email: String, password: String) {
        viewModelScope.launch {
            val loggedInUser = api.login(email, password)
            _user.value = loggedInUser
        }
    }
}

// In your Activity or Fragment
lifecycleScope.launch {
    viewModel.user.collect { user ->
        if (user != null) {
            showDashboard(user)
        } else {
            showLoginScreen()
        }
    }
}

StateFlow automatically deduplicates: if you emit the same value twice, only one emission reaches collectors. This prevents unnecessary UI recompositions in Compose or redundant view updates.

You can also combine flows to derive new state:

kotlin
class FilterViewModel : ViewModel() {
    val category = MutableStateFlow("all")
    val priceRange = MutableStateFlow(0..1000)

    val filteredProducts = combine(category, priceRange) { cat, range ->
        api.getProducts(cat, range)
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5000),
        emptyList()
    )
}

The stateIn operator converts a cold flow into a StateFlow, with smart lifecycle management via WhileSubscribed—it stops collecting upstream when no one is listening and restarts when needed.

SharedFlow for broadcasting events

SharedFlow broadcasts events to multiple independent collectors. Unlike cold flows, all collectors receive the same emission; unlike StateFlow, it doesn't hold state and doesn't deduplicate.

SharedFlow is ideal for one-to-many communication: navigation events, snackbar notifications, or sync signals in a ride-hailing app where multiple parts of the UI need to react to a single server event.

kotlin
class EventBus {
    private val _events = MutableSharedFlow(extraBufferCapacity = 10)
    val events: SharedFlow = _events.asSharedFlow()

    suspend fun emit(event: AppEvent) {
        _events.emit(event)
    }
}

sealed class AppEvent {
    data class UserLoggedOut(val reason: String) : AppEvent()
    data class OrderStatusChanged(val orderId: String, val status: String) : AppEvent()
    object SyncRequested : AppEvent()
}

// Usage in your Activity
lifecycleScope.launchWhenStarted {
    eventBus.events.collect { event ->
        when (event) {
            is AppEvent.UserLoggedOut -> showLoginScreen()
            is AppEvent.OrderStatusChanged -> updateOrderUI(event)
            is AppEvent.SyncRequested -> performSync()
        }
    }
}

The extraBufferCapacity parameter lets you buffer events so they don't get lost if emitted faster than collectors can consume them. Without buffering, a fast emission to a slow collector will suspend or fail depending on your configuration.

You can also use replay to replay recent emissions to new collectors—useful if you want latecomers to catch up on the last few events:

kotlin
private val _events = MutableSharedFlow(
    replay = 5,
    extraBufferCapacity = 10
)

Key operators for real-world flows

Raw flows are rarely enough. Operators let you transform, combine, and throttle streams in practical ways.

map and filter

Transform or select values:

kotlin
flow {
    emit(1)
    emit(2)
    emit(3)
}.filter { it > 1 }
    .map { it * 10 }
    .collect { println(it) } // prints: 20, 30

debounce

Emit only after a specified time with no upstream emissions. Essential for search input in travel or e-commerce apps:

kotlin
val searchQuery = MutableStateFlow("")

val searchResults = searchQuery
    .debounce(300) // Wait 300ms after user stops typing
    .distinctUntilChanged()
    .flatMapLatest { query ->
        if (query.isEmpty()) flowOf(emptyList())
        else api.search(query)
    }
    .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

combine and zip

Merge multiple flows:

kotlin
// combine: emit whenever any source changes
val userAndOrders = combine(
    userFlow,
    ordersFlow
) { user, orders ->
    Pair(user, orders)
}

// zip: emit when all sources have a new value
val pairs = flow1.zip(flow2) { a, b ->
    Pair(a, b)
}

flatMapLatest

Switch to a new inner flow, canceling the previous one. Use when an upstream value change should abandon ongoing work:

kotlin
val userId = MutableStateFlow(1)

val userProfile = userId.flatMapLatest { id ->
    api.getUserProfile(id) // Old request is cancelled if userId changes
}

Common mistakes

  • Forgetting to scope: Collecting from a flow in viewModelScope or lifecycleScope ensures it cancels when the ViewModel or Activity dies. Unscoped flows can leak.
  • Using StateFlow for events: If you emit a logout event via StateFlow, late collectors will replay it forever. Use SharedFlow for one-off events.
  • Blocking collectors: Never do heavy work directly in collect. Use map or flatMapLatest to do work inside the flow.
  • Overusing replay in SharedFlow: Replaying too many events or holding large objects can waste memory. Be selective.
  • Ignoring back-pressure: If your emitter is much faster than your collector, use buffer, conflate, or dropLatest to handle it gracefully.

Key takeaways

  • Cold flows are lazy and independent per collector—use them for per-request operations like API calls.
  • StateFlow holds state, deduplicates, and broadcasts to all collectors—ideal for UI state and derived data.
  • SharedFlow broadcasts events without holding state—use for one-to-many signaling like navigation or sync events.
  • Operators like debounce, flatMapLatest, and combine let you compose complex async logic declaratively.
  • Always collect within a proper scope (viewModelScope, lifecycleScope) to prevent memory leaks.
  • Choose the right flow type: cold for independence, StateFlow for state, SharedFlow for events.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles