androidengineers.Book a session

Jetpack Compose System Design

Side-Effects (Launched/Disposable/Derived)

article25 minHard

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

APIWhen 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
SideEffectSync Compose state to external (non-Compose) APIs every recomposition
derivedStateOfComputed values that change less often than their sources
snapshotFlowBridge Compose state to a Kotlin Flow for use in coroutines

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Side-Effects (Launched/Disposable/Derived) | Android System Design | Android Engineers