Lesson 20 of 83 advanced

Compose Side Effects: LaunchedEffect, SideEffect, DisposableEffect & derivedStateOf

Control when and how effects run — the most commonly misused Compose APIs in senior interviews

Open interactive version (quiz + challenge)

Real-world analogy

Side effects in Compose are like scheduled maintenance on a live production server. You can't just run them at any random time — you need controlled windows. LaunchedEffect is like a scheduled job that re-runs when a parameter changes. DisposableEffect is like a job that also has a cleanup script that runs before the next execution. SideEffect is like a tiny synchronous note passed to the monitoring system on every successful deployment. derivedStateOf is like a pre-computed dashboard metric — only recalculated when its inputs change.

What is it?

Side effect APIs in Jetpack Compose provide controlled, lifecycle-aware windows for code that interacts with the outside world. LaunchedEffect runs coroutines keyed to state changes. DisposableEffect adds cleanup semantics. SideEffect synchronizes Compose state to non-Compose systems after every successful recomposition. rememberCoroutineScope enables event-driven coroutine launches. derivedStateOf memoizes expensive computations. produceState and snapshotFlow bridge between Compose and coroutine-based reactive systems.

Real-world relevance

In a SaaS collaboration app, when a user opens a document, LaunchedEffect(documentId) starts a WebSocket subscription coroutine to receive real-time edits. DisposableEffect(documentId) registers the presence indicator (user is viewing) and onDispose {} unregisters it when the user navigates away. snapshotFlow { searchQuery } with debounce(300) prevents a search API call on every keystroke. derivedStateOf computes the filtered collaborator list without re-filtering on every cursor position change.

Key points

Code example

// LaunchedEffect — load data when documentId changes
@Composable
fun DocumentScreen(documentId: String, viewModel: DocumentViewModel = hiltViewModel()) {
    // Re-runs whenever documentId changes; cancelled on composable exit
    LaunchedEffect(documentId) {
        viewModel.loadDocument(documentId)
        viewModel.subscribeToEdits(documentId)
    }

    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    DocumentContent(uiState)
}

// DisposableEffect — presence tracking with cleanup
@Composable
fun CollaboratorPresence(documentId: String, userId: String) {
    DisposableEffect(documentId, userId) {
        PresenceManager.setPresent(documentId, userId)
        onDispose {
            PresenceManager.setAbsent(documentId, userId)  // Always called on key change or exit
        }
    }
}

// rememberCoroutineScope — user-initiated action
@Composable
fun SendMessageBar(onSend: suspend (String) -> Unit) {
    var text by rememberSaveable { mutableStateOf("") }
    val scope = rememberCoroutineScope()

    Row {
        TextField(value = text, onValueChange = { text = it })
        Button(onClick = {
            scope.launch {       // Launched on user tap, NOT on recomposition
                onSend(text)
                text = ""
            }
        }) { Text("Send") }
    }
}

// derivedStateOf — expensive filter only recomputes when list changes
@Composable
fun CollaboratorList(viewModel: CollabViewModel = hiltViewModel()) {
    val allUsers by viewModel.users.collectAsStateWithLifecycle()
    var searchQuery by remember { mutableStateOf("") }

    // Without derivedStateOf: filter runs on EVERY recomposition (e.g. cursor blinks)
    // With derivedStateOf: filter runs only when allUsers or searchQuery changes
    val filteredUsers by remember {
        derivedStateOf { allUsers.filter { it.name.contains(searchQuery, ignoreCase = true) } }
    }

    LazyColumn {
        items(filteredUsers, key = { it.id }) { user -> UserRow(user) }
    }
}

// snapshotFlow — debounce a Compose state search query
@Composable
fun SearchWithDebounce(viewModel: SearchViewModel = hiltViewModel()) {
    var query by remember { mutableStateOf("") }

    LaunchedEffect(Unit) {
        snapshotFlow { query }
            .debounce(300)
            .distinctUntilChanged()
            .collect { debouncedQuery -> viewModel.search(debouncedQuery) }
    }

    TextField(value = query, onValueChange = { query = it })
}

// produceState — wrap a suspend call into Compose State
@Composable
fun UserAvatar(userId: String) {
    val avatarUrl by produceState<String?>(initialValue = null, userId) {
        value = AvatarRepository.fetchUrl(userId)  // suspend call
    }
    if (avatarUrl != null) AsyncImage(model = avatarUrl, contentDescription = null)
}

// SideEffect — push Compose state to analytics (non-Compose system)
@Composable
fun TrackScreen(screenName: String) {
    SideEffect {
        Analytics.setCurrentScreen(screenName)  // Called after every successful recomposition
    }
}

Line-by-line walkthrough

  1. 1. LaunchedEffect(documentId) — whenever documentId changes, cancel the previous coroutine and start a new one, loading the new document and subscribing to its real-time edits
  2. 2. The coroutine inside LaunchedEffect is automatically cancelled when DocumentScreen leaves the composition — no manual cleanup needed for the subscription
  3. 3. DisposableEffect(documentId, userId) runs when the composable enters composition or when either key changes
  4. 4. onDispose { PresenceManager.setAbsent(...) } is guaranteed to run before the next effect execution or when the composable exits — this is how you avoid presence leaks
  5. 5. rememberCoroutineScope() returns a scope tied to the composable's lifetime — scope.launch { } inside the Button's onClick fires only when the user taps, not on recomposition
  6. 6. derivedStateOf { allUsers.filter {...} } wraps the computation in a snapshot-aware memo — the filter only re-executes when allUsers or searchQuery actually changes
  7. 7. snapshotFlow { query } converts the Compose State 'query' into a regular Flow — then standard Flow operators like debounce and distinctUntilChanged apply cleanly
  8. 8. LaunchedEffect(Unit) around snapshotFlow means the collection coroutine starts once and lives until the composable exits — the flow itself emits on every query change
  9. 9. produceState(initialValue = null, userId) launches a coroutine; setting 'value = ...' updates the returned State and triggers recomposition
  10. 10. SideEffect { Analytics.setCurrentScreen(screenName) } pushes the current Compose state into a non-Compose system synchronously after each successful recomposition

Spot the bug

@Composable
fun NotificationScreen(userId: String, viewModel: NotifViewModel = hiltViewModel()) {

    // Bug 1
    LaunchedEffect(Unit) {
        viewModel.loadNotifications(userId)
    }

    val notifications by viewModel.notifications.collectAsStateWithLifecycle()
    var filterRead by remember { mutableStateOf(false) }

    // Bug 2
    val unread = notifications.filter { !it.isRead }

    // Bug 3
    val scope = rememberCoroutineScope()
    LaunchedEffect(Unit) {
        scope.launch { viewModel.trackScreenView() }
    }

    // Bug 4
    DisposableEffect(userId) {
        NotifManager.subscribe(userId)
    }

    LazyColumn {
        items(if (filterRead) notifications else unread) { notif ->
            NotifRow(notif)
        }
    }
}
Need a hint?
Consider: what happens when userId changes, expensive operations on recomposition, mixing scope types, and missing onDispose.
Show answer
Bug 1: LaunchedEffect(Unit) does NOT rerun when userId changes — if the screen recomposes with a different userId (e.g. account switch), notifications for the old userId are loaded. Fix: LaunchedEffect(userId) so it reruns when userId changes. Bug 2: notifications.filter { !it.isRead } runs on EVERY recomposition (including filterRead toggle, scroll events, etc.). Fix: val unread by remember { derivedStateOf { notifications.filter { !it.isRead } } } — only recomputes when notifications changes. Bug 3: Using rememberCoroutineScope inside LaunchedEffect mixes two scoping mechanisms unnecessarily — LaunchedEffect already gives you a coroutine context. Fix: Just call viewModel.trackScreenView() directly inside LaunchedEffect(Unit) without scope.launch. Bug 4: DisposableEffect block does not return an onDispose { } — this is a compile error in Compose. The block MUST return onDispose { }. Fix: DisposableEffect(userId) { NotifManager.subscribe(userId); onDispose { NotifManager.unsubscribe(userId) } }.

Explain like I'm 5

Imagine your composable is a chef who can only cook — they're not supposed to also be calling customers on the phone while cooking (that would be chaotic). Side effect APIs are the rules for when the chef IS allowed to make calls. LaunchedEffect is like: 'when a new order comes in (key changes), start a new phone call and hang up the old one.' DisposableEffect is the same but you also clean up before hanging up. rememberCoroutineScope is like: 'only call when the customer rings the bell (button tap).' derivedStateOf is like pre-computing the daily special board — only rewrite it when ingredients change, not every second.

Fun fact

DisposableEffect was specifically designed to replace the pattern where developers put cleanup logic in onDispose inside remember { } — a fragile pattern that could miss cleanup if the composable was removed without recomposition. Google made onDispose a first-class return value from DisposableEffect so the compiler enforces that you always provide cleanup.

Hands-on challenge

Build a real-time typing indicator feature for a chat screen. When the local user types in the message TextField, send a 'typing' event to the server with 300ms debounce using snapshotFlow. Register presence on the screen using DisposableEffect (unregister on exit). Use LaunchedEffect(chatRoomId) to subscribe to incoming messages. Use derivedStateOf to compute unread count from the messages list. Handle the send button with rememberCoroutineScope.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery