PCSalt
YouTube GitHub
Back to Jetpack Compose
Jetpack Compose · 3 min read

Side Effects in Compose — LaunchedEffect, DisposableEffect & More

Master side effects in Jetpack Compose — LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, and when to use each.


This is Part 7 of a 10-part series on Jetpack Compose.

  1. Your First Screen
  2. State Management
  3. Navigation
  4. Lists
  5. Theming
  6. Forms
  7. Side Effects (this post)
  8. Custom Layouts
  9. API Integration
  10. Migration from XML

What is a side effect?

A composable should be a pure function of its inputs — given the same state, produce the same UI. But real apps need side effects: loading data when a screen appears, starting a timer, subscribing to a sensor, tracking analytics events.

Compose provides effect handlers that let you run side effects at the right time in the composition lifecycle.

LaunchedEffect — Run a coroutine tied to composition

LaunchedEffect runs a suspend function when the composable enters the composition. It cancels when the composable leaves or when its key changes.

@Composable
fun UserScreen(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }

    LaunchedEffect(userId) {
        user = userRepository.getUser(userId) // suspend function
    }

    user?.let { UserProfile(it) } ?: LoadingIndicator()
}

When userId changes, the previous coroutine is cancelled and a new one starts. When UserScreen leaves the composition, the coroutine is cancelled.

Key behavior

// Runs once when composable enters composition
LaunchedEffect(Unit) {
    analytics.trackScreenView("Home")
}

// Runs every time userId changes
LaunchedEffect(userId) {
    user = fetchUser(userId)
}

// Runs when either key changes
LaunchedEffect(userId, sortOrder) {
    users = fetchUsers(userId, sortOrder)
}

Unit as key means “run once.” A changing value as key means “restart when the value changes.”

Common pattern: Collecting flows

@Composable
fun EventHandler(viewModel: MyViewModel) {
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is Event.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
                is Event.Navigate -> navController.navigate(event.route)
            }
        }
    }
}

The flow collection runs as long as the composable is in the composition. When it leaves, collection stops automatically.

DisposableEffect — Setup and cleanup

DisposableEffect is for effects that need cleanup — listeners, callbacks, observers.

@Composable
fun LocationTracker(onLocationChanged: (Location) -> Unit) {
    val context = LocalContext.current

    DisposableEffect(Unit) {
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        val listener = LocationListener { location ->
            onLocationChanged(location)
        }

        locationManager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER,
            5000L,
            10f,
            listener
        )

        onDispose {
            locationManager.removeUpdates(listener)
        }
    }
}

onDispose runs when:

  • The composable leaves the composition
  • The key changes (the old effect is disposed, then a new one starts)

Lifecycle observer

@Composable
fun LifecycleAwareScreen(onResume: () -> Unit, onPause: () -> Unit) {
    val lifecycleOwner = LocalLifecycleOwner.current

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

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

Registered on composition, cleaned up on disposal. No memory leaks.

SideEffect — Run on every successful recomposition

SideEffect runs after every successful recomposition. Use it to sync Compose state with non-Compose code.

@Composable
fun AnalyticsTracker(screenName: String) {
    SideEffect {
        analytics.setCurrentScreen(screenName)
    }
}

This updates the analytics SDK’s current screen on every recomposition. No key — it runs every time.

Use SideEffect for:

  • Updating external state that should reflect the latest Compose state
  • Logging recomposition
  • Syncing with non-Compose libraries

Don’t use it for:

  • Coroutines (use LaunchedEffect)
  • Cleanup (use DisposableEffect)
  • Heavy work (it runs on the main thread)

rememberCoroutineScope — Launch coroutines from callbacks

LaunchedEffect is tied to composition. But sometimes you need to launch a coroutine from a user action (button click):

@Composable
fun SaveButton(data: FormData) {
    val scope = rememberCoroutineScope()
    val snackbarHostState = remember { SnackbarHostState() }

    Button(onClick = {
        scope.launch {
            try {
                repository.save(data)
                snackbarHostState.showSnackbar("Saved!")
            } catch (e: Exception) {
                snackbarHostState.showSnackbar("Error: ${e.message}")
            }
        }
    }) {
        Text("Save")
    }
}

rememberCoroutineScope gives you a scope tied to the composable’s lifecycle. When the composable leaves, the scope is cancelled.

Don’t use rememberCoroutineScope to load data on composition — use LaunchedEffect for that. rememberCoroutineScope is for event-driven coroutines (clicks, gestures).

rememberUpdatedState — Capture latest value in long-running effects

When a LaunchedEffect runs for a long time, the values it captured at launch might become stale:

@Composable
fun Timer(onTimeout: () -> Unit) {
    // Problem: if onTimeout changes during the delay, we call the old one
    LaunchedEffect(Unit) {
        delay(5000)
        onTimeout() // might be stale!
    }
}

Fix with rememberUpdatedState:

@Composable
fun Timer(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(5000)
        currentOnTimeout() // always calls the latest version
    }
}

rememberUpdatedState keeps a reference to the latest value without restarting the effect.

produceState — Convert non-Compose state to Compose state

produceState creates a Compose state from a non-Compose data source:

@Composable
fun NetworkStatus(): State<Boolean> {
    return produceState(initialValue = true) {
        val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { value = true }
            override fun onLost(network: Network) { value = false }
        }

        val connectivityManager = context.getSystemService<ConnectivityManager>()
        connectivityManager?.registerDefaultNetworkCallback(callback)

        awaitDispose {
            connectivityManager?.unregisterNetworkCallback(callback)
        }
    }
}

// Usage
val isOnline by networkStatus()
if (!isOnline) {
    OfflineBanner()
}

produceState combines LaunchedEffect + DisposableEffect + state creation. Set value inside the lambda, and awaitDispose handles cleanup.

derivedStateOf — Avoid unnecessary recomposition

Already covered in the State Management post, but worth repeating:

@Composable
fun FilteredList(items: List<Item>, query: String) {
    // Recomputes only when items or query actually change
    val filtered by remember(items) {
        derivedStateOf {
            items.filter { it.name.contains(query, ignoreCase = true) }
        }
    }

    LazyColumn {
        items(filtered, key = { it.id }) { item ->
            ItemRow(item)
        }
    }
}

When to use what

ScenarioEffect handler
Load data on screen entryLaunchedEffect(key)
Collect a FlowLaunchedEffect(Unit)
Register/unregister a listenerDisposableEffect(key)
Launch coroutine from a clickrememberCoroutineScope
Sync with external non-Compose stateSideEffect
Convert callback-based API to stateproduceState
Capture latest callback in long-running effectrememberUpdatedState
Compute derived state efficientlyderivedStateOf

Common mistakes

Running effects without keys

// Runs on EVERY recomposition — probably not what you want
LaunchedEffect(Unit) {
    fetchData() // but you want it to re-fetch when userId changes
}

// Fix: use the right key
LaunchedEffect(userId) {
    fetchData(userId)
}

Launching coroutines inside composable body

// Wrong — launches a new coroutine on every recomposition
@Composable
fun BadExample() {
    val scope = rememberCoroutineScope()
    scope.launch { // runs on EVERY recomposition!
        loadData()
    }
}

// Right — use LaunchedEffect
@Composable
fun GoodExample() {
    LaunchedEffect(Unit) {
        loadData()
    }
}

Forgetting cleanup

// Memory leak — listener never removed
DisposableEffect(Unit) {
    sensor.registerListener(listener)
    onDispose { } // empty dispose!
}

// Fixed
DisposableEffect(Unit) {
    sensor.registerListener(listener)
    onDispose {
        sensor.unregisterListener(listener)
    }
}

Summary

Side effects in Compose are explicit and lifecycle-aware:

  • LaunchedEffect — coroutines tied to composition/keys
  • DisposableEffect — setup + cleanup
  • SideEffect — sync on every recomposition
  • rememberCoroutineScope — event-driven coroutines
  • produceState — bridge non-Compose → Compose state
  • rememberUpdatedState — capture latest value in long effects

The key insight: Compose recomposes frequently. Effects give you control over when and how non-UI work happens, with automatic cleanup to prevent leaks.

Next: Custom Layouts & Modifiers in Compose.