Composable functions should be pure — given the same inputs, they produce the same UI with no side effects. But real apps need side effects: launching coroutines, setting up listeners, logging analytics, updating external state. Compose provides a set of effect handlers — composable functions that let you run side effects in a controlled, lifecycle-aware way. Use the wrong one and you get duplicate API calls, leaked listeners, or effects that fire on every recomposition. This guide covers every effect handler with precise identification and clear rules for when to use each.


What is a Side Effect in Compose?

// A SIDE EFFECT is any operation that:
// - Escapes the scope of the composable function
// - Changes state outside the composable
// - Interacts with external systems (network, database, sensors, analytics)

// Composable functions can be called:
// - Multiple times (recomposition)
// - In any order
// - In parallel
// - Or even SKIPPED entirely

// So side effects inside a composable without proper handling:
// ❌ May run more times than expected
// ❌ May run when they shouldn't
// ❌ May not clean up properly

// ❌ BAD — API call runs on EVERY recomposition
@Composable
fun ArticleScreen(articleId: String) {
    val data = api.getArticle(articleId)   // runs every recomposition!
    Text(data.title)
}

// ✅ GOOD — LaunchedEffect runs once, controlled by the key
@Composable
fun ArticleScreen(articleId: String) {
    var data by remember { mutableStateOf<Article?>(null) }

    LaunchedEffect(articleId) {
        data = api.getArticle(articleId)   // runs once per articleId
    }

    data?.let { Text(it.title) }
}

LaunchedEffect — The Most Common Effect

LaunchedEffect is a composable function from androidx.compose.runtime that launches a coroutine scoped to the Composition. The coroutine is cancelled when LaunchedEffect leaves the Composition or when its key changes.

// ═══ LaunchedEffect Lifecycle ═════════════════════════════════════════
//
//  Composable enters Composition:
//    → LaunchedEffect starts coroutine
//
//  Key changes (e.g., articleId changes):
//    → Old coroutine is CANCELLED
//    → New coroutine is LAUNCHED with new key
//
//  Composable leaves Composition (navigated away):
//    → Coroutine is CANCELLED (automatic cleanup)
//
//  Recomposition (same key):
//    → Coroutine keeps running — NOT restarted
// LaunchedEffect(key) { } — launches coroutine when key changes
// LaunchedEffect is a COMPOSABLE FUNCTION
// The lambda receiver is CoroutineScope — you can call suspend functions inside

// Run ONCE when composable enters the Composition
LaunchedEffect(Unit) {
    // Unit as key = runs once, never re-launches (key never changes)
    analytics.logScreenView("home")
}

// Run when a specific value changes
@Composable
fun ArticleDetail(articleId: String) {
    val viewModel: DetailViewModel = hiltViewModel()

    LaunchedEffect(articleId) {
        // Runs when articleId changes
        // If user navigates to a different article, old coroutine cancels,
        // new one launches with the new articleId
        viewModel.loadArticle(articleId)
    }
}

// Collecting one-time events (snackbar, navigation)
@Composable
fun ArticleScreen(viewModel: ArticleViewModel = hiltViewModel()) {
    val snackbarHostState = remember { SnackbarHostState() }
    // SnackbarHostState is a CLASS from Material3

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            // collect is a SUSPEND FUNCTION on Flow — suspends forever
            // This is correct inside LaunchedEffect — the coroutine lives
            // as long as the composable is in the Composition
            when (event) {
                is UiEvent.ShowSnackbar ->
                    snackbarHostState.showSnackbar(event.message)
                    // showSnackbar() is a SUSPEND FUNCTION on SnackbarHostState
            }
        }
    }

    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { /* ... */ }
}

// Multiple keys — restarts when ANY key changes
LaunchedEffect(query, filter) {
    // Restarts when query OR filter changes
    val results = repository.search(query, filter)
    updateResults(results)
}

LaunchedEffect with key patterns

// KEY = Unit → runs once (on enter)
LaunchedEffect(Unit) { analytics.logScreenView("home") }

// KEY = a value → runs when that value changes
LaunchedEffect(articleId) { viewModel.load(articleId) }

// KEY = multiple values → runs when ANY changes
LaunchedEffect(query, page) { viewModel.search(query, page) }

// KEY = true/false → runs when condition becomes true
LaunchedEffect(isConnected) {
    if (isConnected) { viewModel.syncPendingData() }
}

// ⚠️ COMMON MISTAKE: using a key that changes on every recomposition
// ❌ This restarts on EVERY recomposition (object recreated each time)
LaunchedEffect(listOf(1, 2, 3)) { /* restarts constantly */ }

// ✅ Use stable keys — primitives, data class instances, state values
LaunchedEffect(articleId) { /* restarts only when articleId changes */ }

DisposableEffect — Setup + Cleanup

DisposableEffect is a composable function for effects that need cleanup when the composable leaves the Composition or when the key changes. Think of it as LaunchedEffect + onDispose.

// ═══ DisposableEffect Lifecycle ═══════════════════════════════════════
//
//  Composable enters Composition:
//    → DisposableEffect body runs (setup)
//
//  Key changes:
//    → onDispose { } runs (cleanup old)
//    → Body runs again (setup new)
//
//  Composable leaves Composition:
//    → onDispose { } runs (cleanup)
// DisposableEffect(key) { ... onDispose { } }
// DisposableEffect is a COMPOSABLE FUNCTION
// The body is NOT a coroutine — it's a regular lambda
// Must end with onDispose { } block for cleanup
// onDispose is a FUNCTION on DisposableEffectScope

// Lifecycle observer
@Composable
fun LifecycleLogger(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) {
    // LocalLifecycleOwner is a COMPOSITION LOCAL (ProvidableCompositionLocal)
    // .current is a PROPERTY that reads the current value from the Composition

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            // LifecycleEventObserver is an INTERFACE from androidx.lifecycle
            println("Lifecycle event: $event")
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        // addObserver() is a FUNCTION on Lifecycle

        onDispose {
            // Called when composable leaves Composition OR key changes
            lifecycleOwner.lifecycle.removeObserver(observer)
            println("Observer removed — cleaned up!")
        }
    }
}

// System broadcast receiver
@Composable
fun BatteryMonitor(context: Context = LocalContext.current) {
    // LocalContext is a COMPOSITION LOCAL that provides the current Context
    var batteryLevel by remember { mutableIntStateOf(100) }

    DisposableEffect(Unit) {
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(ctx: Context, intent: Intent) {
                val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
                batteryLevel = level
            }
        }

        val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
        context.registerReceiver(receiver, filter)

        onDispose {
            context.unregisterReceiver(receiver)
            // Always unregister to prevent leaks!
        }
    }

    Text("Battery: $batteryLevel%")
}

// Map view lifecycle
@Composable
fun MapScreen(mapView: MapView) {
    val lifecycle = LocalLifecycleOwner.current.lifecycle

    DisposableEffect(lifecycle, mapView) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_RESUME -> mapView.onResume()
                Lifecycle.Event.ON_PAUSE -> mapView.onPause()
                else -> { }
            }
        }
        lifecycle.addObserver(observer)

        onDispose {
            lifecycle.removeObserver(observer)
            mapView.onDestroy()
        }
    }
}

// ⚠️ DisposableEffect MUST have onDispose as the last statement
// ❌ Compile error:
DisposableEffect(Unit) {
    setupListener()
    // missing onDispose!
}

// ✅ Always end with onDispose (even if empty):
DisposableEffect(Unit) {
    setupListener()
    onDispose { }   // empty cleanup is valid
}

SideEffect — Run on Every Successful Recomposition

SideEffect is a composable function that runs after every successful recomposition. It’s used to publish Compose state to non-Compose code (bridge between Compose and imperative code).

// SideEffect { } — runs after EVERY successful recomposition
// SideEffect is a COMPOSABLE FUNCTION
// The lambda is NOT a coroutine — no suspend functions allowed
// NOT keyed — runs every time the composable recomposes

// Use case: sync Compose state to non-Compose systems
@Composable
fun AnalyticsScreen(screenName: String, user: User) {

    SideEffect {
        // Runs after every successful recomposition
        // Use to update external systems with current Compose state
        analytics.setCurrentScreen(screenName)
        analytics.setUserId(user.id)
        // If user changes → recomposition → SideEffect runs → analytics updated
    }

    // ... screen content ...
}

// Use case: update a callback ref without recreating the callback
@Composable
fun CallbackBridge(onEvent: (Event) -> Unit) {
    val currentOnEvent by rememberUpdatedState(onEvent)
    // rememberUpdatedState() is a COMPOSABLE FUNCTION
    // Returns State<T> that always holds the LATEST value
    // Without restarting effects that capture it

    LaunchedEffect(Unit) {
        someExternalStream.collect { event ->
            currentOnEvent(event)
            // Always calls the LATEST onEvent, even if it changed during the effect
        }
    }
}

// SideEffect vs LaunchedEffect vs DisposableEffect:
// SideEffect:       no key, runs every recomposition, no coroutine, no cleanup
// LaunchedEffect:   keyed, launches coroutine, auto-cancelled on key change/leave
// DisposableEffect: keyed, runs setup + cleanup, no coroutine

rememberCoroutineScope — Coroutines from Event Handlers

rememberCoroutineScope is a composable function that returns a CoroutineScope tied to the Composition. Use it when you need to launch coroutines from event handlers (onClick, etc.) rather than from the composition itself.

// rememberCoroutineScope() returns a CoroutineScope
// The scope is CANCELLED when the composable leaves the Composition
// rememberCoroutineScope is a COMPOSABLE FUNCTION

@Composable
fun ScrollToTopButton(listState: LazyListState) {
    val coroutineScope = rememberCoroutineScope()
    // Returns CoroutineScope — an INTERFACE from kotlinx.coroutines

    Button(onClick = {
        // onClick is a regular lambda — NOT @Composable, NOT suspend
        // You can't call suspend functions directly here
        // You can't use LaunchedEffect here (it's not @Composable context)

        coroutineScope.launch {
            // launch is an EXTENSION FUNCTION on CoroutineScope (builder)
            listState.animateScrollToItem(0)
            // animateScrollToItem() is a SUSPEND FUNCTION on LazyListState
        }
    }) {
        Text("Scroll to Top")
    }
}

// Snackbar from a button click
@Composable
fun DeleteButton(snackbarHostState: SnackbarHostState, onDelete: () -> Unit) {
    val scope = rememberCoroutineScope()

    Button(onClick = {
        onDelete()
        scope.launch {
            snackbarHostState.showSnackbar(
                message = "Item deleted",
                actionLabel = "Undo",
                duration = SnackbarDuration.Short
                // SnackbarDuration is an ENUM CLASS — Short, Long, Indefinite
            )
        }
    }) {
        Text("Delete")
    }
}

// ⚠️ LaunchedEffect vs rememberCoroutineScope:
// LaunchedEffect → for effects that should run AUTOMATICALLY when state changes
// rememberCoroutineScope → for effects triggered by USER EVENTS (onClick, etc.)

// ❌ Don't use rememberCoroutineScope for automatic effects
val scope = rememberCoroutineScope()
scope.launch { viewModel.load() }   // ❌ runs on every recomposition!

// ✅ Use LaunchedEffect for automatic effects
LaunchedEffect(Unit) { viewModel.load() }   // ✅ runs once

rememberUpdatedState — Capturing Latest Value

rememberUpdatedState is a composable function that always holds the latest value of a parameter, even inside long-running effects that captured an old value:

// Problem: LaunchedEffect captures the initial callback, not the latest one
@Composable
fun SplashScreen(onTimeout: () -> Unit) {

    // ❌ BAD — captures the initial onTimeout, not the latest
    LaunchedEffect(Unit) {
        delay(3000)
        onTimeout()   // might call a stale reference!
    }

    // ✅ GOOD — always calls the latest onTimeout
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    // rememberUpdatedState() is a COMPOSABLE FUNCTION
    // Returns State<T> that is UPDATED on every recomposition
    // But doesn't restart the LaunchedEffect (key is still Unit)

    LaunchedEffect(Unit) {
        delay(3000)
        currentOnTimeout()   // always the latest value!
    }

    // How it works:
    // 1. LaunchedEffect(Unit) starts with key=Unit (never restarts)
    // 2. Parent recomposes, passes a new onTimeout lambda
    // 3. rememberUpdatedState updates its stored value (new lambda)
    // 4. When delay finishes, currentOnTimeout reads the LATEST lambda
    // 5. The latest lambda is called — not the stale original
}

// Use rememberUpdatedState when:
// - A LaunchedEffect or DisposableEffect captures a lambda/callback
// - That lambda might change between recompositions
// - You DON'T want to restart the effect when it changes

snapshotFlow — Convert Compose State to Flow

snapshotFlow is a top-level function that converts Compose State reads into a cold Flow. The Flow emits whenever the state value changes:

// snapshotFlow { } reads Compose state and emits changes as a Flow
// snapshotFlow is a TOP-LEVEL FUNCTION from compose.runtime

@Composable
fun ScrollTracker(listState: LazyListState) {

    LaunchedEffect(listState) {
        // Convert Compose state to Flow — apply Flow operators
        snapshotFlow { listState.firstVisibleItemIndex }
            // Reads firstVisibleItemIndex (Compose State)
            // Emits a new value whenever it changes
            .distinctUntilChanged()
            // distinctUntilChanged() is an EXTENSION FUNCTION on Flow
            .filter { it > 0 }
            // filter() is an EXTENSION FUNCTION on Flow
            .collect { index ->
                analytics.logEvent("scrolled_to_item_$index")
            }
    }
}

// Debounce search input (Compose state → Flow → debounce)
@Composable
fun DebouncedSearch(query: String, onSearch: (String) -> Unit) {

    LaunchedEffect(Unit) {
        snapshotFlow { query }
            .debounce(300)
            // debounce() is an EXTENSION FUNCTION on Flow
            // Waits 300ms after last emission before emitting
            .filter { it.length >= 2 }
            .distinctUntilChanged()
            .collect { debouncedQuery ->
                onSearch(debouncedQuery)
            }
    }
}

// snapshotFlow is the bridge from Compose state → reactive Flow world
// Use it when you need Flow operators (debounce, filter, map) on Compose state

produceState — Convert External Data to Compose State

produceState is a composable function that launches a coroutine and converts its result into Compose State. It’s the inverse of snapshotFlow — external data → Compose state:

// produceState(initialValue) { } → returns State<T>
// produceState is a COMPOSABLE FUNCTION
// Launches a coroutine scoped to the Composition
// Inside the lambda, set value = ... to update the state

@Composable
fun NetworkImage(url: String): State<ImageBitmap?> {
    return produceState<ImageBitmap?>(initialValue = null, key1 = url) {
        // This lambda runs in a coroutine
        // "value" is a PROPERTY on ProduceStateScope — setting it updates the State
        value = loadImageFromNetwork(url)   // suspend function
    }
}

// Practical example — loading data without ViewModel
@Composable
fun ArticleContent(articleId: String) {
    val article by produceState<Article?>(initialValue = null, key1 = articleId) {
        value = repository.getArticle(articleId)   // suspend
    }

    if (article != null) {
        Text(article!!.title)
    } else {
        CircularProgressIndicator()
    }
}

// produceState with cleanup (awaitDispose)
@Composable
fun LocationTracker(): State<Location?> {
    return produceState<Location?>(initialValue = null) {
        val callback = object : LocationCallback() {
            override fun onLocationResult(result: LocationResult) {
                value = result.lastLocation   // updates Compose state
            }
        }
        locationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())

        awaitDispose {
            // awaitDispose { } is a SUSPEND FUNCTION on ProduceStateScope
            // Called when the composable leaves Composition
            locationClient.removeLocationUpdates(callback)
        }
    }
}

// When to use produceState vs ViewModel:
// produceState → simple, one-off data loading without ViewModel
// ViewModel → production apps, surviving rotation, complex state management
// In practice, prefer ViewModel — produceState is for simple cases

Effect Handler Decision Tree

// Which effect handler should I use?
//
// Q: Do you need to launch a COROUTINE?
// ├── YES → Q: Is it triggered by a USER EVENT (onClick)?
// │         ├── YES → rememberCoroutineScope + scope.launch { }
// │         └── NO → Q: Does it need CLEANUP?
// │                  ├── YES → (rare) Use LaunchedEffect with try/finally
// │                  └── NO → LaunchedEffect(key) { }
// │
// └── NO → Q: Do you need SETUP + CLEANUP (listener, observer)?
//          ├── YES → DisposableEffect(key) { onDispose { } }
//          └── NO → Q: Should it run on EVERY recomposition?
//                   ├── YES → SideEffect { }
//                   └── NO → You probably don't need an effect!

// Quick reference:
// ┌─────────────────────────┬──────────────┬──────────┬─────────┬───────────┐
// │ Effect                  │ Coroutine?   │ Keyed?   │ Cleanup?│ When?     │
// ├─────────────────────────┼──────────────┼──────────┼─────────┼───────────┤
// │ LaunchedEffect          │ ✅ Yes        │ ✅ Yes    │ Auto    │ Key change│
// │ DisposableEffect        │ ❌ No         │ ✅ Yes    │ Manual  │ Key change│
// │ SideEffect              │ ❌ No         │ ❌ No     │ ❌ No    │ Every     │
// │ rememberCoroutineScope  │ ✅ Yes        │ ❌ N/A    │ Auto    │ On demand │
// │ rememberUpdatedState    │ ❌ No         │ ❌ N/A    │ ❌ No    │ Every     │
// │ snapshotFlow            │ ✅ (in LE)    │ ❌ N/A    │ ❌ No    │ State Δ   │
// │ produceState            │ ✅ Yes        │ ✅ Yes    │ Await   │ Key change│
// └─────────────────────────┴──────────────┴──────────┴─────────┴───────────┘

Common Mistakes to Avoid

Mistake 1: Running suspend functions without LaunchedEffect

// ❌ Suspend function called directly in composition — runs every recomposition
@Composable
fun ArticleScreen(viewModel: ArticleViewModel) {
    viewModel.loadArticles()   // runs on EVERY recomposition!
}

// ✅ Use LaunchedEffect — runs once, controlled by key
@Composable
fun ArticleScreen(viewModel: ArticleViewModel) {
    LaunchedEffect(Unit) {
        viewModel.loadArticles()   // runs once when composable enters Composition
    }
}

Mistake 2: Using rememberCoroutineScope for automatic effects

// ❌ scope.launch in composition body — launches on every recomposition
@Composable
fun ArticleScreen() {
    val scope = rememberCoroutineScope()
    scope.launch { api.getData() }   // launches new coroutine on EVERY recomposition!
}

// ✅ LaunchedEffect for automatic effects
@Composable
fun ArticleScreen() {
    LaunchedEffect(Unit) { api.getData() }   // once
}

// ✅ rememberCoroutineScope for user-triggered events
@Composable
fun ArticleScreen() {
    val scope = rememberCoroutineScope()
    Button(onClick = { scope.launch { api.getData() } }) {   // on click only
        Text("Load")
    }
}

Mistake 3: Missing onDispose in DisposableEffect

// ❌ Compile error — onDispose is required
DisposableEffect(Unit) {
    registerListener(listener)
    // Missing onDispose!
}

// ✅ Always provide onDispose (even if empty)
DisposableEffect(Unit) {
    registerListener(listener)
    onDispose { unregisterListener(listener) }
}

// ✅ Empty cleanup is valid when no cleanup needed
DisposableEffect(Unit) {
    logSomething()
    onDispose { }   // nothing to clean up
}

Mistake 4: Using LaunchedEffect with unstable keys

// ❌ New list created every recomposition — key changes every time
@Composable
fun Screen(items: List<String>) {
    LaunchedEffect(items) {
        // Restarts on EVERY recomposition because List equality
        // depends on the reference, not content
        processItems(items)
    }
}

// ✅ Use a stable key that represents meaningful change
@Composable
fun Screen(items: List<String>) {
    LaunchedEffect(items.size) {
        // Only restarts when the number of items changes
        processItems(items)
    }
}

// ✅ Or use a data class / primitive that has stable equality
LaunchedEffect(articleId) { }       // String — stable
LaunchedEffect(page, filter) { }    // primitives — stable

Mistake 5: Not using rememberUpdatedState for captured lambdas

// ❌ Captured lambda is stale — calls the OLD callback
@Composable
fun Timer(onComplete: () -> Unit) {
    LaunchedEffect(Unit) {
        delay(5000)
        onComplete()   // might be stale if parent recomposed with new lambda!
    }
}

// ✅ rememberUpdatedState always gives the latest value
@Composable
fun Timer(onComplete: () -> Unit) {
    val currentOnComplete by rememberUpdatedState(onComplete)
    LaunchedEffect(Unit) {
        delay(5000)
        currentOnComplete()   // always the latest!
    }
}

Summary

  • LaunchedEffect(key) (composable function) — launches a coroutine scoped to the Composition, cancelled on key change or leave; use for async operations triggered by state changes
  • DisposableEffect(key) (composable function) — runs setup code with mandatory onDispose { } cleanup; use for listeners, observers, callbacks that need unregistering
  • SideEffect (composable function) — runs after every successful recomposition; use to sync Compose state to external non-Compose systems
  • rememberCoroutineScope() (composable function) — returns a CoroutineScope for launching coroutines from event handlers (onClick); never use in the composition body
  • rememberUpdatedState() (composable function) — always holds the latest value of a parameter without restarting effects; use inside LaunchedEffect for captured lambdas
  • snapshotFlow { } (top-level function) — converts Compose State reads into a cold Flow; use when you need Flow operators (debounce, filter) on Compose state
  • produceState() (composable function) — launches a coroutine and exposes the result as Compose State; use for simple data loading without ViewModel
  • onDispose { } is a function on DisposableEffectScope — mandatory last statement in DisposableEffect
  • awaitDispose { } is a suspend function on ProduceStateScope — cleanup for produceState
  • Use LaunchedEffect for automatic async work, rememberCoroutineScope for user-triggered async work
  • Use stable keys (primitives, data classes) for LaunchedEffect/DisposableEffect to avoid unnecessary restarts
  • Never run side effects directly in the composition body — always use an effect handler

Side effects are where Compose meets the real world — network calls, listeners, analytics, navigation. The key rule is simple: never run side effects directly in a composable function. Pick the right effect handler based on whether you need a coroutine (LaunchedEffect), cleanup (DisposableEffect), every-recomposition sync (SideEffect), or user-triggered launch (rememberCoroutineScope) — and your Compose code stays predictable, leak-free, and correct.

Happy coding!