Side effects in Compose: real-world lessons and examples
Managing side effects in Jetpack Compose is one of the trickier aspects of the framework, especially when you're coming from an Activity or Fragment-based background. Getting it wrong leads to memory leaks, repeated work, and state that drifts out of sync with the UI. We'll walk through the main tools at your disposal—LaunchedEffect, DisposableEffect, rememberCoroutineScope, and SideEffect—and show you exactly when and how to use each one through patterns we've seen work in production.
Understanding side effects in Compose
A side effect is any operation that reaches outside the composition: launching a network request, writing to a database, logging an event, or updating a mutable object. The composition itself is meant to be pure—given the same inputs, it should produce the same UI tree. Side effects break that rule, and that's fine; we just need to control where and when they run.
Compose gives you several primitives to manage this, each with a specific job. Using the wrong one leads to effects firing on every recomposition, or not firing when you expect them to. Understanding the lifecycle of each is essential.
LaunchedEffect — Run suspend work when keys change
When to use it: Whenever you need to launch a coroutine inside a composable and have it tied to the composable's lifecycle. This is your go-to for one-shot async work, polling loops, and anything that needs to observe recomposition triggers.
Real-world example: You're building a search screen. When the user enters a query, you want to fetch results. You don't want the request to fire on every keystroke; you want to debounce and only call the API after they've stopped typing for 500ms.
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
var query by remember { mutableStateOf("") }
val results by viewModel.results.collectAsState(initial = emptyList())
LaunchedEffect(query) {
if (query.isNotBlank()) {
delay(500) // Debounce
viewModel.search(query)
}
}
Column {
TextField(
value = query,
onValueChange = { query = it },
label = { Text("Search") }
)
LazyColumn {
items(results) { item ->
SearchResultRow(item)
}
}
}
}The key here is the query parameter in the LaunchedEffect signature. This is the "key" that triggers re-execution. When query changes, the previous coroutine is cancelled and a new one starts. If you don't include the right key, the debounce won't work—or it'll fire too often.
Another pattern: one-shot initialization. If you want something to run exactly once when the composable enters the composition, pass an empty key block or a constant like Unit:
LaunchedEffect(Unit) {
viewModel.loadInitialData()
}The coroutine launched by LaunchedEffect is bound to the composable's lifecycle. When the composable leaves the composition, the coroutine is cancelled automatically. This is a huge win for memory safety.
DisposableEffect — Register and clean up listeners
When to use it: When you need to register a listener or observer and guarantee cleanup. Think lifecycle callbacks, sensor listeners, or subscribing to a database or event bus.
Real-world example: You're displaying a live map and want to track device location. You need to register a location listener when the composable enters composition and unregister it when it leaves.
@Composable
fun MapScreen(locationProvider: LocationProvider) {
var userLocation by remember { mutableStateOf(null) }
DisposableEffect(Unit) {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
userLocation = LatLng(location.latitude, location.longitude)
}
}
locationProvider.registerListener(listener)
onDispose {
locationProvider.unregisterListener(listener)
}
}
if (userLocation != null) {
GoogleMap(center = userLocation)
} else {
Text("Waiting for location...")
}
}The structure is important: DisposableEffect runs when its keys change, and it must return an onDispose lambda where you clean up. Forgetting cleanup leads to listener leaks and battery drain.
Database subscriptions: Another common use case is setting up a database listener and cleaning it up:
DisposableEffect(userId) {
val subscription = userDatabase.observeUser(userId) { user ->
// Update state
}
onDispose {
subscription.cancel()
}
}When userId changes, the old listener is disposed and a new one is registered for the new user. Clean handoff with no leaks.
rememberCoroutineScope — Launch from click handlers
When to use it: When you need to launch a coroutine from an event handler—a button click, a text field focus change, a drag gesture. This is not for automatic effects; it's for user-initiated work.
Real-world example: A login form. When the user taps the login button, you need to POST credentials and handle the response. You can't use LaunchedEffect because there's no specific recomposition trigger; you need direct control.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
Column {
TextField(value = email, onValueChange = { email = it })
TextField(value = password, onValueChange = { password = it })
Button(
onClick = {
scope.launch {
isLoading = true
errorMessage = ""
try {
val result = viewModel.login(email, password)
navigateToHome()
} catch (e: Exception) {
errorMessage = e.message ?: "Login failed"
} finally {
isLoading = false
}
}
},
enabled = !isLoading
) {
Text(if (isLoading) "Logging in..." else "Login")
}
if (errorMessage.isNotEmpty()) {
Text(errorMessage, color = Color.Red)
}
}
}The scope returned by rememberCoroutineScope() is tied to the composable's lifecycle. Any coroutines launched via that scope will be cancelled when the composable leaves the composition. You don't have to manage cancellation manually.
Pro tip: Use rememberCoroutineScope for user actions, not for automatic effects. If you're doing something that should happen without user input, LaunchedEffect is the right tool.
SideEffect — Publish state to non-Compose code after composition
When to use it: When you need to write state to a non-Compose system after a successful composition. This is less common in modern Compose codebases, but it's essential when integrating with legacy code or updating external mutable state.
Real-world example: You have a custom analytics system that isn't Compose-aware. After your screen renders, you want to log a page view with the current screen parameters. You can't do this in the composable body itself because the composition might recompose multiple times.
@Composable
fun ProductDetailScreen(productId: String) {
val analytics = LocalAnalytics.current
SideEffect {
analytics.logPageView(
screenName = "ProductDetail",
productId = productId
)
}
// Rest of the UI...
}SideEffect runs after every successful composition. If you want it to run only when certain values change, pass those as keys (in newer versions of Compose) or combine it with other patterns. More often, you'll use LaunchedEffect for this kind of logic, but SideEffect is handy when you need synchronous, non-coroutine work.
Another pattern: syncing state back to a ViewModel or external holder:
@Composable
fun FormScreen(viewModel: FormViewModel) {
var formData by remember { mutableStateOf(FormData()) }
SideEffect {
viewModel.updateFormState(formData)
}
// Form fields that update formData...
}This ensures that after every recomposition, the ViewModel is in sync with the Compose state.
Common mistakes
- Forgetting to include keys in LaunchedEffect. If you omit the key or use a constant, the effect won't re-trigger when the dependency changes. Common symptom: debounce doesn't work, or data stays stale.
- Launching coroutines in the composable body without a scope. Writing
viewModel.load()directly in the composable will run on every recomposition. Always wrap async work inLaunchedEffectorrememberCoroutineScope. - Forgetting onDispose in DisposableEffect. Your listeners will leak. Always return the cleanup lambda.
- Using SideEffect for async work.
SideEffectis synchronous. If you need to launch a coroutine, useLaunchedEffect. - Not cancelling long-running work. If you launch a coroutine with
rememberCoroutineScopeand navigate away, the scope cancels it for you. But if you useGlobalScopeor a custom scope, you're responsible for cancellation—avoid this.
Key takeaways
- LaunchedEffect is for automatic, key-driven async work. Use it for initialization, polling, and reacting to state changes.
- DisposableEffect is for registering listeners and observers. Always clean up in
onDispose. - rememberCoroutineScope is for launching coroutines from user events like button clicks. It's tied to the composable's lifecycle.
- SideEffect is for synchronous writes to non-Compose systems after composition. Use it sparingly and prefer
LaunchedEffectfor most cases. - Always think about cleanup and cancellation. Compose makes it easy, but you have to use the right tool.
- Keys matter. Get the keys wrong and your effects won't fire as expected.






Comments
Loading comments…