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
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
- The rule: composables must be side-effect free — Compose may call your composable multiple times, in any order, potentially in parallel on multiple threads. Any code that modifies external state (network calls, analytics, subscriptions) must be in a side effect API — not in the composable body directly.
- LaunchedEffect(key) — Launches a coroutine scoped to the composable's lifecycle. The coroutine is cancelled and relaunched whenever the key changes. LaunchedEffect(Unit) runs once on entry. LaunchedEffect(userId) reruns when userId changes. The coroutine is cancelled when the composable leaves the composition.
- LaunchedEffect key selection — The key is the identity signal. LaunchedEffect(Unit) or LaunchedEffect(true) = run once. LaunchedEffect(someId) = rerun when someId changes. LaunchedEffect(state.event) = react to one-time events. Choosing the wrong key causes effects to run too often or not at all — a common interview trap.
- SideEffect — Runs synchronously after every SUCCESSFUL recomposition. Used to push Compose state into non-Compose systems (e.g. updating an analytics object, synchronizing a legacy View). It is NOT a coroutine — it is synchronous and runs on the composition thread. Rare in practice but tested in interviews.
- DisposableEffect(key) — For effects that need cleanup. The block runs when the key changes or the composable leaves composition. Must return onDispose { } which is the cleanup lambda. Classic use: subscribe to an event bus or register a listener onEntry, unregister onDispose. Like LaunchedEffect but synchronous and cleanup-aware.
- rememberCoroutineScope — Returns a CoroutineScope tied to the composable's lifetime in the composition. Unlike LaunchedEffect, it does NOT automatically launch — you call scope.launch { } manually, typically from a click handler or event callback. Use this when you need to launch coroutines from event handlers, not from composition.
- derivedStateOf for memoized computation — val filtered by remember { derivedStateOf { list.filter { it.isActive } } } — the filtered list only recomputes when 'list' changes, not on every recomposition. Without derivedStateOf, filter() runs on every recomposition regardless. Critical for performance in large lists.
- produceState — Converts non-Compose async sources (Flow, LiveData, callbacks) into Compose State. Launches a coroutine, you set value = ... to update the state. Useful for wrapping suspend functions directly into State without a ViewModel. produceState(initialValue = Loading) { value = repo.load() }.
- snapshotFlow — Converts Compose State into a Flow. snapshotFlow { state.value } emits a new value whenever state changes. Used to bridge Compose state to coroutine-based operators (debounce, distinctUntilChanged, etc.) — e.g. debouncing a search query that lives in Compose state.
- Common mistake: LaunchedEffect vs rememberCoroutineScope — LaunchedEffect is for composition-driven side effects (run this when X changes). rememberCoroutineScope is for user-driven side effects (run this when user taps). Using LaunchedEffect for click handlers means the coroutine starts on every recomposition. Using rememberCoroutineScope for data loading means it only loads when explicitly triggered.
- One-time events via Channel or SharedFlow — For navigation or snackbar triggers, wrap the event in a Channel in the ViewModel and collect in LaunchedEffect. Avoid using MutableStateFlow for one-shot events — it replays the last value on collection, causing the navigation to fire again after process death restoration.
- Effect key Unit vs null vs specific value — LaunchedEffect(Unit) and LaunchedEffect(true) both run once. LaunchedEffect(null) also runs once (null never changes). LaunchedEffect(viewModel) runs once if viewModel identity is stable. Passing a rapidly changing value as key (like a scroll offset) will cancel/relaunch the coroutine on every scroll — a severe performance bug.
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. 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. The coroutine inside LaunchedEffect is automatically cancelled when DocumentScreen leaves the composition — no manual cleanup needed for the subscription
- 3. DisposableEffect(documentId, userId) runs when the composable enters composition or when either key changes
- 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. 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. derivedStateOf { allUsers.filter {...} } wraps the computation in a snapshot-aware memo — the filter only re-executes when allUsers or searchQuery actually changes
- 7. snapshotFlow { query } converts the Compose State 'query' into a regular Flow — then standard Flow operators like debounce and distinctUntilChanged apply cleanly
- 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. produceState(initialValue = null, userId) launches a coroutine; setting 'value = ...' updates the returned State and triggers recomposition
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Side Effects in Compose — Official Guide (Android Developers)
- derivedStateOf API Reference (Android Developers)
- Compose Side Effects Explained — ProAndroidDev (ProAndroidDev)
- Advanced Compose: Side Effects — Android Dev Summit (YouTube / Google)
- snapshotFlow and State-to-Flow bridge (Android Developers)