Compose composables are side-effect-free by design — they only transform state into UI. But real apps need side effects: launching coroutines, registering listeners, tracking analytics. Compose provides a controlled set of effect APIs for this.
The Problem: Why Not Just LaunchCoroutineScope?
Composables can recompose many times. Any code outside the Compose effect APIs can run on every recomposition:
// ❌ This launches a new coroutine on EVERY recomposition
@Composable
fun ArticleScreen(articleId: String) {
val scope = rememberCoroutineScope()
scope.launch { fetchArticle(articleId) } // WRONG: runs on every recompose
// ...
}
LaunchedEffect: Coroutines with a Lifecycle
LaunchedEffect launches a coroutine when the composable enters composition, cancels it when the composable leaves, and re-launches when keys change:
@Composable
fun ArticleScreen(articleId: String, viewModel: ArticleViewModel = viewModel()) {
// Launches once when articleId first appears; re-launches if articleId changes
LaunchedEffect(articleId) {
viewModel.loadArticle(articleId)
}
// Launches once (Unit key = never changes)
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is ArticleEffect.NavigateBack -> navController.popBackStack()
is ArticleEffect.ShowError -> snackbarHostState.showSnackbar(effect.message)
}
}
}
}
Key rule: the key(s) determine when the effect re-runs. Unit = once; a state value = re-run on every change.
rememberCoroutineScope: User-Triggered Coroutines
For coroutines triggered by user actions (click handlers), use rememberCoroutineScope:
@Composable
fun BookmarkButton(article: Article, viewModel: ArticleViewModel) {
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { // ✅ user-triggered, not recomposition-triggered
viewModel.toggleBookmark(article.id)
}
}) {
Text("Bookmark")
}
}
DisposableEffect: Registering / Unregistering Listeners
DisposableEffect runs setup code when entering composition and cleanup code when leaving:
@Composable
fun LifecycleObserverEffect(
lifecycle: Lifecycle,
onStart: () -> Unit,
onStop: () -> Unit
) {
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> onStart()
Lifecycle.Event.ON_STOP -> onStop()
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer) // cleanup — called on leave or key change
}
}
}
// Usage:
@Composable
fun VideoPlayerScreen() {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LifecycleObserverEffect(
lifecycle = lifecycle,
onStart = { player.play() },
onStop = { player.pause() }
)
}
SideEffect: Synchronizing Non-Compose Code
SideEffect runs on every successful recomposition. Use it to push Compose state to non-Compose code:
@Composable
fun AnalyticsTracker(screenName: String, analytics: Analytics) {
SideEffect {
// Called after every successful recomposition
analytics.setCurrentScreen(screenName) // sync to non-Compose analytics SDK
}
}
derivedStateOf: Efficiently Computed State
derivedStateOf avoids recomposition when source state changes but the computed value does not:
@Composable
fun ArticleList(articles: List<Article>, filterText: String) {
// ❌ Without derivedStateOf: recomposes on every filterText keystroke even if result is same
val filtered = articles.filter { it.title.contains(filterText, ignoreCase = true) }
// ✅ With derivedStateOf: only recomposes when the filtered result actually changes
val filtered by remember(articles) {
derivedStateOf { articles.filter { it.title.contains(filterText, ignoreCase = true) } }
}
LazyColumn {
items(filtered) { ArticleItem(it) }
}
}
snapshotFlow: Bridge State to Flow
snapshotFlow converts Compose state into a Kotlin Flow for use in coroutines:
@Composable
fun AutoSaveEditor(viewModel: EditorViewModel) {
val listState = rememberLazyListState()
// Emit scroll position as a Flow, then debounce before saving
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.debounce(500)
.collect { index ->
viewModel.saveScrollPosition(index)
}
}
}
Key Takeaways
| API | When to use |
|---|---|
LaunchedEffect(key) | Coroutine tied to composition; re-runs on key change |
rememberCoroutineScope() | User-triggered coroutines in event handlers |
DisposableEffect(key) | Register/unregister listeners; requires onDispose cleanup |
SideEffect | Sync Compose state to external (non-Compose) APIs every recomposition |
derivedStateOf | Computed values that change less often than their sources |
snapshotFlow | Bridge Compose state to a Kotlin Flow for use in coroutines |