Side Effects & Lifecycle in Compose

Technical Round

Side Effects & Lifecycle in Compose

Side effects and lifecycle are fundamental to real-world Compose development. Every company using Compose will ask about LaunchedEffect, DisposableEffect, and how Compose’s lifecycle maps to the Activity lifecycle. Getting these wrong causes resource leaks, crashes, and subtle bugs.

What is a side effect in Compose?

A side effect is any operation that happens outside the scope of the composable function. Network calls, logging, writing to a database, showing a toast, starting a coroutine — all side effects. Composable functions should be pure, but real apps need side effects to do useful work.

Compose provides effect handlers (LaunchedEffect, DisposableEffect, SideEffect) to run side effects safely. Running side effects directly in the composable body is dangerous because the function re-executes on every recomposition.

What is the Compose lifecycle?

A composable has three lifecycle events:

This is simpler than the Activity lifecycle. There’s no “paused” or “stopped” state — a composable is either in the composition or not.

What is LaunchedEffect and when do you use it?

LaunchedEffect launches a coroutine scoped to the composition. When the composable enters composition, the coroutine starts. When it leaves composition or the key changes, the coroutine is cancelled. I use it whenever I need to launch a coroutine inside a composable — data loading, animations, collecting events.

@Composable
fun UserProfileScreen(userId: String, viewModel: ProfileViewModel) {
    LaunchedEffect(userId) {
        viewModel.loadProfile(userId)
    }

    val state by viewModel.uiState.collectAsStateWithLifecycle()
    ProfileContent(state)
}

The key (userId) controls when the effect restarts. When userId changes, the current coroutine is cancelled and a new one starts. If you pass Unit as the key, the effect runs once and never restarts.

What is DisposableEffect and how is it different from LaunchedEffect?

DisposableEffect is for side effects that need cleanup. It provides an onDispose block that runs when the composable leaves composition or the key changes. I use it for registering and unregistering listeners, callbacks, or observers.

@Composable
fun LocationTracker(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) {
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) {
                startLocationUpdates()
            } else if (event == Lifecycle.Event.ON_PAUSE) {
                stopLocationUpdates()
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

The key difference: DisposableEffect doesn’t launch a coroutine. It runs a synchronous block and provides cleanup. LaunchedEffect is for async work, DisposableEffect is for registering and unregistering resources.

What is SideEffect and when do you use it?

SideEffect runs after every successful composition — both initial and every recomposition. It has no key and no cleanup. I use it to sync Compose state to non-Compose code.

@Composable
fun AnalyticsTracker(screenName: String) {
    SideEffect {
        analytics.setCurrentScreen(screenName)
    }
}

It only runs after composition succeeds — if composition is cancelled, the effect doesn’t run. Keep it lightweight since it fires on every recomposition.

What is the difference between rememberCoroutineScope and LaunchedEffect?

rememberCoroutineScope gives you a CoroutineScope tied to the composable’s lifecycle. The scope is cancelled when the composable leaves composition. The difference is that you control when the coroutine launches — typically in response to user events.

@Composable
fun SubmitButton(viewModel: FormViewModel) {
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch {
            viewModel.submitForm()
        }
    }) {
        Text("Submit")
    }
}

Use LaunchedEffect when the coroutine should start automatically based on state. Use rememberCoroutineScope when the coroutine should start in response to a callback like a button click. You can’t call LaunchedEffect inside a click handler — it’s a composable function, not a regular function.

What is rememberUpdatedState and why is it needed?

rememberUpdatedState captures the latest value of a parameter inside a long-running effect without restarting it. When a LaunchedEffect uses Unit as its key, it captures the initial values of its closure. If those values change during recomposition, the effect still uses the old ones.

@Composable
fun SplashScreen(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(3000)
        currentOnTimeout()
    }
}

Without rememberUpdatedState, if the parent recomposes and passes a different onTimeout lambda, the LaunchedEffect would still call the original one. rememberUpdatedState keeps a mutable state reference that always points to the latest value.

What is the difference between LaunchedEffect(Unit) and LaunchedEffect(true)?

Functionally, no difference. Both create a LaunchedEffect that runs once and never restarts because the key never changes. The convention is to use Unit because it communicates intent more clearly — “this effect has no meaningful key.”

The important distinction is between constant keys and meaningful keys. LaunchedEffect(userId) restarts when userId changes. LaunchedEffect(Unit) never restarts. Choosing the wrong key is a common source of bugs — using Unit when you should use a parameter means the effect captures stale values.

What is produceState?

produceState converts a non-Compose data source into Compose state. It launches a coroutine that sets a state value over time. It combines remember, mutableStateOf, and LaunchedEffect into a single API.

@Composable
fun NetworkImage(url: String): State<ImageResult> {
    return produceState<ImageResult>(initialValue = ImageResult.Loading, url) {
        val image = loadImage(url)
        value = if (image != null) {
            ImageResult.Success(image)
        } else {
            ImageResult.Error
        }
    }
}

The coroutine restarts when the key (url) changes, just like LaunchedEffect. I use it when I have a suspend function or callback-based API that produces state — it’s cleaner than manually combining remember with LaunchedEffect.

What is snapshotFlow?

snapshotFlow converts Compose State reads into a Kotlin Flow. It creates a flow that emits whenever any state object read inside its block changes. It’s the inverse of collectAsState — instead of converting a Flow to Compose state, it converts Compose state to a Flow.

@Composable
fun SearchScreen(listState: LazyListState) {
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .filter { it > 5 }
            .collect {
                analytics.logScrollDepth(it)
            }
    }
}

I use it when I need Flow operators like debounce, filter, or distinctUntilChanged on Compose state. It only emits when the value actually changes.

How does the Compose lifecycle relate to the Activity lifecycle?

The Compose UI tree lives inside a ComposeView in the Activity’s view hierarchy. When the Activity is created and setContent is called, the initial composition happens. When the Activity is destroyed, the composition is disposed.

During configuration changes, the composition is disposed and recreated. remember values are lost, but rememberSaveable values survive because they’re persisted to the savedInstanceState Bundle. ViewModel state also survives because the ViewModelStore is retained.

To observe the Activity lifecycle inside composables, I use LocalLifecycleOwner.current with a DisposableEffect:

@Composable
fun CameraPreview() {
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_START -> openCamera()
                Lifecycle.Event.ON_STOP -> closeCamera()
                else -> {}
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

How do you handle one-time events like navigation and snackbars?

One-time events are tricky because recomposition can cause effects to re-execute. The common pattern is a Channel in the ViewModel collected inside a LaunchedEffect:

class OrderViewModel : ViewModel() {
    private val _events = Channel<OrderEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()

    fun submitOrder() {
        viewModelScope.launch {
            repository.submitOrder()
            _events.send(OrderEvent.ShowSuccess)
        }
    }
}

@Composable
fun OrderScreen(viewModel: OrderViewModel, onNavigateBack: () -> Unit) {
    val snackbarHostState = remember { SnackbarHostState() }

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                OrderEvent.ShowSuccess -> snackbarHostState.showSnackbar("Order placed")
                OrderEvent.NavigateBack -> onNavigateBack()
            }
        }
    }
}

Channel guarantees each event is consumed exactly once. Using StateFlow for one-time events is a mistake — it replays the last value on resubscription, which can trigger the event again after a configuration change.

How does Compose handle effects during configuration changes?

When a configuration change occurs and the Activity recreates, the entire composition is disposed and recreated from scratch. All LaunchedEffect coroutines are cancelled. All DisposableEffect cleanup runs. All remember values are lost.

On the new composition, effects start fresh — LaunchedEffect launches new coroutines, DisposableEffect runs its setup block again. State from rememberSaveable is restored, and ViewModel state is still available.

Any in-flight network request inside a LaunchedEffect gets cancelled during rotation. If I need work to survive configuration changes, I launch it in the ViewModel’s viewModelScope instead. LaunchedEffect is for UI-scoped work like animations and event collection. ViewModel scope is for business logic that should outlive the composable.

What is the key() composable and when do you need it?

key() overrides Compose’s default positional identity. Normally, Compose identifies a composable by its position in the source code. Inside loops or conditional blocks where composables can change order, positional identity breaks.

@Composable
fun UserList(users: List<User>) {
    Column {
        for (user in users) {
            key(user.id) {
                UserRow(user)
            }
        }
    }
}

Without key(), removing the first user makes Compose think the second item became the first, the third became the second, and so on. All items recompose with wrong data and effects restart unnecessarily. With key(user.id), Compose correctly matches each composable to its data even when the list changes. LazyColumn handles this through its key parameter in items().

How do multiple LaunchedEffects work in the same composable?

Each LaunchedEffect is independent. They have separate coroutines, separate keys, and separate lifecycles. They run concurrently and don’t affect each other.

@Composable
fun DashboardScreen(userId: String) {
    LaunchedEffect(userId) {
        viewModel.loadProfile(userId)
    }

    LaunchedEffect(userId) {
        viewModel.loadNotifications(userId)
    }

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            handleEvent(event)
        }
    }
}

The first two restart when userId changes. The third runs once and collects events for the lifetime of the composable. If I need coroutines to run sequentially, I put them in a single LaunchedEffect.

What happens when you call effects conditionally?

Conditional effects work because Compose treats them like any other composable. When the condition is true, the effect enters composition and starts. When the condition becomes false, the effect leaves composition, the coroutine is cancelled, and cleanup runs.

@Composable
fun NotificationBanner(showBanner: Boolean) {
    if (showBanner) {
        LaunchedEffect(Unit) {
            delay(5000)
            dismissBanner()
        }
    }
}

This is actually useful — the effect only exists while the condition holds. But nesting effects inside other effects is a code smell. If I need to launch a coroutine from inside a LaunchedEffect, I just use the coroutine scope directly since I’m already inside a suspend function.

How does process death affect Compose state?

Process death kills the entire process. remember values, ViewModel data, and all in-memory state are gone. Only data saved through rememberSaveable or SavedStateHandle survives because it’s serialized to a Bundle stored outside the process.

@Composable
fun FilterScreen() {
    var searchQuery by rememberSaveable { mutableStateOf("") }

    var selectedFilter by rememberSaveable(stateSaver = FilterSaver) {
        mutableStateOf(Filter.Default)
    }
}

val FilterSaver = Saver<Filter, String>(
    save = { it.name },
    restore = { Filter.valueOf(it) }
)

I keep rememberSaveable for lightweight UI state the user expects to persist — scroll position, search queries, selected tabs. Heavy data lives in the ViewModel and gets re-fetched after process death.

What is movableContentOf and how does it interact with lifecycle?

movableContentOf lets you move a composable from one part of the tree to another without it leaving and re-entering composition. Normally, moving a composable to a different parent causes it to leave composition (state lost, effects disposed) and re-enter (fresh state, effects restart). movableContentOf preserves everything.

@Composable
fun AdaptiveLayout(isWideScreen: Boolean) {
    val playerContent = remember {
        movableContentOf {
            VideoPlayer(url = videoUrl)
        }
    }

    if (isWideScreen) {
        Row {
            PlaylistPanel()
            playerContent()
        }
    } else {
        Column {
            playerContent()
            PlaylistPanel()
        }
    }
}

The VideoPlayer keeps its playback position, buffered data, and internal state when switching between layouts. Without movableContentOf, the player would restart from scratch every time the layout changes.

What is the difference between collectAsState and collectAsStateWithLifecycle?

collectAsState collects from a Flow regardless of the app’s lifecycle state. Even when the app is in the background, the collection continues. collectAsStateWithLifecycle is lifecycle-aware — it stops collecting when the lifecycle drops below a certain state (default is STARTED) and restarts when the lifecycle resumes.

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    // Keeps collecting in background — wastes resources
    val state1 by viewModel.uiState.collectAsState()

    // Stops collecting when app goes to background
    val state2 by viewModel.uiState.collectAsStateWithLifecycle()
}

I always use collectAsStateWithLifecycle for UI state. It prevents unnecessary work in the background and avoids potential crashes from updating UI when the app isn’t visible. It requires the androidx.lifecycle:lifecycle-runtime-compose dependency.

What is the order of execution between SideEffect, LaunchedEffect, and DisposableEffect?

DisposableEffect runs its setup block synchronously during composition. SideEffect runs after composition completes successfully. LaunchedEffect launches its coroutine after composition, but the coroutine is dispatched — it doesn’t run immediately.

So the order is: DisposableEffect setup runs first (during composition), then SideEffect runs (after composition succeeds), then LaunchedEffect coroutine starts executing (dispatched). On disposal, DisposableEffect’s onDispose block runs, and LaunchedEffect’s coroutine gets cancelled.

How do you handle back press events in Compose?

BackHandler is a composable that intercepts back presses. Under the hood, it uses a DisposableEffect to register an OnBackPressedCallback with the OnBackPressedDispatcher.

@Composable
fun SearchScreen(onClose: () -> Unit) {
    var showResults by remember { mutableStateOf(false) }

    BackHandler(enabled = showResults) {
        showResults = false
    }

    if (!showResults) {
        BackHandler {
            onClose()
        }
    }
}

Since BackHandler is a composable, it follows composition lifecycle. It can be conditional, and multiple BackHandler calls stack — the most recently composed enabled one handles the back press first. This is much cleaner than the old Activity onBackPressed approach.

Common Follow-ups