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

Collecting flows safely in Compose: a step-by-step guide

Avoid wasted work in the background.

Expona Admin7 min read

The problem with collecting flows in Compose

When we started building a real-time inventory dashboard for an e-commerce platform, we noticed something alarming: background work was continuing even after users minimized the app. Every time we collected a Flow directly in a composable body, we were spinning up database queries, API calls, and state updates that served no one. After a few weeks in production, we realized we needed a better strategy for collecting flows safely in Compose: a step-by-step guide to avoid wasted work in the background.

The challenge isn't that Flows are inherently problematic—they're excellent for reactive data streams. The issue is that Compose recompositions and lifecycle events don't naturally align. A composable can recompose multiple times per second, and if you're launching a collector in the composable body without lifecycle awareness, you'll have multiple collectors running simultaneously, or worse, collectors that persist after the user leaves your screen.

Why collectAsStateWithLifecycle() is your lifeline

The Jetpack Compose team introduced collectAsStateWithLifecycle() to bridge the gap between Compose's rendering lifecycle and Android's activity/fragment lifecycle. This function does something elegant: it collects a Flow and converts it to a Compose State, but it only actively collects when the Lifecycle is in the STARTED state.

Let's look at a concrete example. Imagine you're building a trading platform where users view real-time price updates:

kotlin
// ViewModel
class PriceViewModel : ViewModel() {
    private val _priceUpdates = MutableSharedFlow<PriceData>()
    val priceUpdates: SharedFlow<PriceData> = _priceUpdates

    fun startWatchingPrice(symbol: String) {
        viewModelScope.launch {
            while (true) {
                val price = fetchPrice(symbol)
                _priceUpdates.emit(price)
                delay(1000)
            }
        }
    }
}

// DON'T DO THIS - collects even when app is backgrounded
@Composable
fun PriceScreen(viewModel: PriceViewModel) {
    val price = remember { mutableStateOf<PriceData?>(null) }
    
    LaunchedEffect(Unit) {
        viewModel.priceUpdates.collect { newPrice ->
            price.value = newPrice
        }
    }
    
    Text("Price: ${price.value?.amount}")
}

The code above looks reasonable, but here's the trap: that LaunchedEffect remains active in the Recomposer even when the Activity moves to the background. The Flow keeps emitting, and your ViewModel keeps fetching data. Over a few hours, this adds up to unnecessary battery drain and data usage.

Now let's fix it:

kotlin
// CORRECT - respects lifecycle automatically
@Composable
fun PriceScreen(viewModel: PriceViewModel) {
    val price: PriceData? by viewModel.priceUpdates
        .collectAsStateWithLifecycle(initialValue = null)
    
    Text("Price: ${price?.amount}")
}

That's the entire change. When the app goes to the background, collectAsStateWithLifecycle() pauses collection. When the app returns to the foreground, collection resumes. No manual lifecycle management needed, no dangling coroutines.

Stops collecting when the app is in background

This is the core benefit, and understanding it deeply is critical for production reliability. The function ties collection to Lifecycle.State.STARTED, which means:

  • When the Activity/Fragment is created or resumed, collection starts.
  • When the Activity/Fragment is paused, collection stops.
  • When the Activity/Fragment is destroyed, the entire job is cancelled.

In a ride-hailing app, imagine a driver checking the current ride status while their phone screen is off. You don't want location tracking, ride update polling, or earnings calculations running in the background unnecessarily. With collectAsStateWithLifecycle(), the moment the screen turns off, that collection pauses.

kotlin
// ViewModel
class RideStatusViewModel : ViewModel() {
    val rideStatus: StateFlow<RideStatus> = flow {
        while (true) {
            val status = apiClient.getRideStatus()
            emit(status)
            delay(2000)
        }
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.Lazily,
            initialValue = RideStatus.Loading
        )
}

// Compose screen
@Composable
fun RideDetailsScreen(viewModel: RideStatusViewModel) {
    val status by viewModel.rideStatus.collectAsStateWithLifecycle()
    
    Column {
        Text("Status: ${status.currentLocation}")
        Text("ETA: ${status.eta}")
    }
}

When the driver switches to another app or locks their phone, the polling stops automatically. No explicit cancellation needed. When they return, it resumes. This pattern scales across dozens of screens in your app without additional plumbing.

Expose StateFlow from ViewModels

For collectAsStateWithLifecycle() to work best, you need to think about your data exposure carefully. The ideal approach is to expose StateFlow instead of regular Flow, because StateFlow has built-in replay semantics and a well-defined current value.

Here's a pattern we've used across CRM and travel platforms:

kotlin
class BookingViewModel : ViewModel() {
    // Private mutable
    private val _bookingState = MutableStateFlow<BookingState>(
        BookingState.Loading
    )
    
    // Public immutable StateFlow
    val bookingState: StateFlow<BookingState> = _bookingState.asStateFlow()
    
    private val _confirmationMessage = MutableSharedFlow<String>()
    val confirmationMessage: SharedFlow<String> = _confirmationMessage.asSharedFlow()

    fun confirmBooking(bookingId: String) {
        viewModelScope.launch {
            try {
                _bookingState.value = BookingState.Processing
                val result = apiClient.confirmBooking(bookingId)
                _bookingState.value = BookingState.Success(result)
                _confirmationMessage.emit("Booking confirmed!")
            } catch (e: Exception) {
                _bookingState.value = BookingState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

@Composable
fun BookingConfirmationScreen(viewModel: BookingViewModel) {
    val state by viewModel.bookingState.collectAsStateWithLifecycle()
    val message by viewModel.confirmationMessage
        .collectAsStateWithLifecycle(initialValue = "")
    
    when (state) {
        is BookingState.Loading -> CircularProgressIndicator()
        is BookingState.Success -> {
            Text("Booking confirmed: ${state.confirmation.id}")
        }
        is BookingState.Error -> {
            Text("Error: ${state.error}", color = Color.Red)
        }
        else -> {}
    }
    
    if (message.isNotEmpty()) {
        Snackbar { Text(message) }
    }
}

Notice the pattern: StateFlow for state that persists (booking details, UI state), SharedFlow for one-off events (confirmation messages, navigation events). This separation makes lifecycle-aware collection natural and predictable.

Avoid collecting in composable bodies manually

This is where many developers trip up. The temptation to write quick collection logic directly in the composable body is real, especially under time pressure:

kotlin
// WRONG - manual collection without lifecycle awareness
@Composable
fun UserProfileScreen(viewModel: UserViewModel) {
    var user by remember { mutableStateOf<User?>(null) }
    
    LaunchedEffect(Unit) {
        viewModel.userFlow.collect { newUser ->
            user = newUser
        }
    }
    
    Text("User: ${user?.name}")
}

// WRONG - even worse, direct collect in body
@Composable
fun UserProfileScreen(viewModel: UserViewModel) {
    val user by remember {
        derivedStateOf {
            var latest: User? = null
            runBlocking {
                viewModel.userFlow.first().let { latest = it }
            }
            latest
        }
    }
    
    Text("User: ${user?.name}")
}

Both approaches have serious problems. The first ignores lifecycle events. The second blocks the composition thread, which will crash your app or cause frame drops. Instead, use lifecycle-aware collection from day one:

kotlin
// CORRECT
@Composable
fun UserProfileScreen(viewModel: UserViewModel) {
    val user by viewModel.userFlow.collectAsStateWithLifecycle()
    
    Text("User: ${user?.name}")
}

This single pattern should become your default for any Flow collection in Compose. It's concise, safe, and handles all the lifecycle plumbing automatically.

Common mistakes

Mistake 1: Using LaunchedEffect without lifecycle awareness. Many developers stick with LaunchedEffect for Flow collection because it's familiar from older Compose code. The problem is that LaunchedEffect ties to Compose's recomposition lifecycle, not Android's activity lifecycle. The collection continues even when the app is backgrounded.

Mistake 2: Collecting regular Flow instead of StateFlow. When you expose a regular Flow from your ViewModel and collect it with collectAsStateWithLifecycle(), you're not getting the benefit of replay. If the Flow emits before the Compose state is ready, that emission is lost. StateFlow guarantees the latest value is available immediately upon collection.

Mistake 3: Not importing the correct extension. Make sure you're using androidx.lifecycle.compose.collectAsStateWithLifecycle() from the lifecycle-runtime-compose dependency, not an older or custom implementation. The built-in version is optimized for this exact use case.

Mistake 4: Forgetting to provide an initialValue. When collecting a Flow that doesn't guarantee an initial emission, always provide an initialValue to avoid null safety issues and composition crashes.

Key takeaways

  • collectAsStateWithLifecycle() automatically pauses Flow collection when your Activity or Fragment moves to the background, eliminating wasted work and battery drain.
  • Expose StateFlow from ViewModels for persistent state, and SharedFlow for one-off events, so lifecycle-aware collection integrates naturally.
  • Never manually collect Flows in composable bodies with LaunchedEffect or other coroutine builders—the built-in lifecycle-aware functions handle this correctly.
  • Always provide an initialValue when collecting Flows to ensure your composables have sensible default rendering behavior before the first emission arrives.
  • This pattern scales from simple single-screen features to complex multi-screen apps with dozens of real-time data sources, and the performance gain is measurable in production.
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