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.
- Your First Screen
- State Management
- Navigation
- Lists
- Theming
- Forms
- Side Effects (this post)
- Custom Layouts
- API Integration
- 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
| Scenario | Effect handler |
|---|---|
| Load data on screen entry | LaunchedEffect(key) |
| Collect a Flow | LaunchedEffect(Unit) |
| Register/unregister a listener | DisposableEffect(key) |
| Launch coroutine from a click | rememberCoroutineScope |
| Sync with external non-Compose state | SideEffect |
| Convert callback-based API to state | produceState |
| Capture latest callback in long-running effect | rememberUpdatedState |
| Compute derived state efficiently | derivedStateOf |
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.