Skip to content
All posts
September 9, 20264 min read

LaunchedEffect, DisposableEffect, SideEffect

A practical guide to Compose's three side effect APIs with real code examples, decision tables, and a framework for choosing the right API every time.

AndroidKotlinJetpack ComposeBest Practices
Share:

You ship a Compose screen, it works on first launch, then a configuration change breaks it. The coroutine keeps running, the listener never unbinds, the state writes outside the valid frame. Side effects in Jetpack Compose are not optional — they are the boundary between your UI and the real world. Three APIs exist for this exact job: LaunchedEffect, DisposableEffect, and SideEffect. Most developers reach for the wrong one and pay the price in crashes and memory leaks.

Compose recomposes aggressively. Every state change triggers a recomposition pass, and your composable function body runs again. If you launch a coroutine, register a callback, or write to a mutable object inside that body without a side effect API, you create duplicate work, leaked resources, and race conditions. The three side effect APIs give you structured, lifecycle-aware hooks that respect recomposition. This post covers each one, shows working code, and gives you a decision framework so you pick the right API every time.

Understanding the Three Side Effect APIs

Compose provides three dedicated side effect composables, each with a distinct lifecycle contract. LaunchedEffect runs a coroutine while the composable is in the composition and cancels it on exit. DisposableEffect registers a callback on enter and requires you to call dispose on exit. SideEffect runs synchronously after every successful recomposition and lets you push state to an external object. They are not interchangeable. Using the wrong one leads to leaked coroutines, orphaned listeners, or lost updates.

Here is the decision table I use when choosing between them:

APITriggers OnCancels OnBest For
LaunchedEffectComposition / key changeComposition leaves or key changesCoroutines, one-shot work, polling
DisposableEffectCompositionComposition leaves or dispose callListeners, callbacks, resource cleanup
SideEffectEvery successful recompositionN/A (runs inline)Syncing state to external objects

[!NOTE] All three APIs are stable and part of

code
androidx.compose.runtime
. You do not need an additional dependency beyond the standard Compose BOM.

Each API solves a different problem. LaunchedEffect is for work that runs over time. DisposableEffect is for work that needs teardown. SideEffect is for work that must reflect the latest state immediately. Confusing them is the single most common side effect bug I see in production Compose codebases.

LaunchedEffect — Coroutines in Composable Scope

LaunchedEffect is the go-to API for any work that runs in a coroutine scope tied to the composable's lifecycle. It accepts a coroutine context key and a block. When the key changes, the previous coroutine cancels and a new one launches. When the composable leaves the composition, the coroutine cancels automatically. This lifecycle binding is what makes it safe.

kotlin
@Composable
fun OrderScreen(
    orderId: String,
    viewModel: OrderViewModel = viewModel()
) {
    val state by viewModel.orderState.collectAsState()

    LaunchedEffect(orderId) {
        viewModel.loadOrder(orderId)
        viewModel.refreshStatus()
    }

    when (state) {
        is OrderState.Loading -> CircularProgressIndicator()
        is OrderState.Success -> OrderDetails(state.order)
        is OrderState.Error -> ErrorMessage(state.message)
    }
}

In this example,

code
loadOrder
and
code
refreshStatus
run inside the composable's coroutine scope. If
code
orderId
changes mid-flight, the previous coroutine cancels and a fresh one starts. No manual cancellation logic needed. The coroutine is scoped to the composable's lifetime, which means it cannot outlive the UI element that launched it.

[!TIP] Always pass a key that uniquely identifies the work. If you pass a constant like

code
Unit
, LaunchedEffect runs once and never restarts. If you pass no key at all, it runs on every recomposition, which is usually not what you want.

A common mistake is calling a suspend function without a coroutine scope. LaunchedEffect gives you one for free. Do not wrap it in

code
rememberCoroutineScope
or
code
viewModelScope
inside the composable — that defeats the lifecycle binding. The whole point is that the coroutine dies when the composable leaves the composition.

[!WARNING] LaunchedEffect cancels the coroutine when the composable leaves the composition. If your work must survive configuration changes, move it to a ViewModel and observe the result in the composable. LaunchedEffect is not a replacement for persistent work.

Another pitfall is throwing an exception inside the LaunchedEffect block. The exception propagates to the coroutine context and cancels the job. Wrap risky operations in a try-catch inside the block. Do not let an unchecked exception kill the entire coroutine scope silently.

kotlin
LaunchedEffect(orderId) {
    try {
        val result = repository.fetchOrder(orderId)
        viewModel.updateState(result)
    } catch (e: IOException) {
        viewModel.setError("Network error: ${e.message}")
    } catch (e: Exception) {
        viewModel.setError("Unexpected error")
        throw e
    }
}

This pattern catches expected failures and updates state, while rethrowing truly unexpected exceptions so they are not swallowed.

DisposableEffect — Cleanup on Recomposition

DisposableEffect is for side effects that require explicit teardown. You register a callback or resource on enter, and you must call

code
onDispose
to clean it up. If the composable recomposes with a new key, the old effect disposes and a new one registers. If the composable leaves the composition entirely, dispose runs once.

kotlin
@Composable
fun LocationTracker(
    locationManager: LocationManager,
    onLocationChanged: (Location) -> Unit
) {
    DisposableEffect(locationManager) {
        val listener = LocationListener { location ->
            onLocationChanged(location)
        }
        locationManager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER,
            1000L,
            10f,
            listener
        )
        onDispose {
            locationManager.removeUpdates(listener)
        }
    }
}

This composable registers a location listener tied to the

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this