Compose Side Effects, Lifecycle and Performance
Compose Side Effects, Lifecycle and Performance
Part 1: Side Effects and Lifecycle
1. What is a Side Effect?
A side effect is work that changes something outside the composable UI.
Examples:
* Calling an API
* Starting a coroutine
* Registering a listener
* Showing a Snackbar
* Sending analytics
* Updating a non-Compose object
Composable functions may run repeatedly, be skipped, or be cancelled. Therefore, side-effect work should not run directly inside the composable body. Use Compose Effect APIs instead. Android: Side-effects in Compose
Wrong
@Composable
fun ProductScreen(viewModel: ProductViewModel) {
viewModel.loadProducts() // May run repeatedly
}Correct
@Composable
fun ProductScreen(viewModel: ProductViewModel) {
LaunchedEffect(Unit) {
viewModel.loadProducts()
}
}---
2. Effect Lifecycle
Effects are connected to their position in the composition.
flowchart LR
E["Effect enters composition"] --> S["Start work"]
S --> K{"Key changed?"}
K -->|Yes| R["Cancel and restart"]
K -->|No| C["Continue"]
C --> L["Leaves composition"]
R --> C
L --> X["Cancel or clean up"]The key controls the effect:
* Same key: existing effect continues
* Changed key: old effect stops and a new one starts
* Composable removed: effect is cancelled or disposed
---
3. LaunchedEffect
Use LaunchedEffect to run a suspend function connected to a composable’s lifecycle.
@Composable
fun ProductScreen(productId: String) {
LaunchedEffect(productId) {
loadProduct(productId)
}
}When productId changes:
1. Previous coroutine is cancelled.
2. A new coroutine starts with the new ID.
When the composable leaves the composition, the coroutine is cancelled.
sequenceDiagram
participant C as Composable
participant E as LaunchedEffect
participant W as Suspend Work
C->>E: Enter with productId 10
E->>W: Start work
C->>E: productId changes to 20
E-->>W: Cancel old work
E->>W: Start new workCommon uses
* Initial screen loading
* Snackbar display
* Animation
* Scrolling
* Collecting a Flow
* Calling suspend functions
Constant key
LaunchedEffect(Unit) {
viewModel.loadProducts()
}This runs once for that effect’s lifetime in the composition.
---
4. DisposableEffect
Use DisposableEffect when work requires cleanup.
Common examples:
* Register and unregister a listener
* Add and remove a lifecycle observer
* Start and stop a sensor callback
* Register and unregister a receiver
@Composable
fun LifecycleLogger(
lifecycleOwner: LifecycleOwner
) {
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
Log.d("Lifecycle", event.name)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}flowchart LR
E["Enter composition"] --> R["Register observer"]
R --> U["Composable remains active"]
U --> D["Leave or key changes"]
D --> C["onDispose: unregister"]onDispose is required and is the correct place for cleanup.
---
5. SideEffect
Use SideEffect to publish Compose State to a non-Compose object after a successful recomposition.
@Composable
fun AnalyticsScreen(
userType: String,
analytics: Analytics
) {
SideEffect {
analytics.setUserType(userType)
}
Text("Home")
}SideEffect runs after every successful recomposition of that composable.
Use it for
* Updating analytics properties
* Updating an external SDK
* Passing Compose State to non-Compose code
Do not use it for one-time API loading because it may run many times.
---
6. produceState
produceState converts asynchronous or non-Compose data into Compose State.
@Composable
fun loadUser(userId: String): State<User?> {
return produceState<User?>(
initialValue = null,
key1 = userId
) {
value = userApi.getUser(userId)
}
}Usage:
val user by loadUser(userId)flowchart LR
A["API or Flow"] --> P["produceState"]
P --> S["Compose State"]
S --> UI["Composable"]It:
* Returns Compose State
* Starts when entering composition
* Restarts when a key changes
* Is cancelled when leaving composition
* Updates the UI when value changes
---
7. rememberCoroutineScope
Use rememberCoroutineScope when a coroutine must start from a UI event.
@Composable
fun SnackbarButton(
snackbarHostState: SnackbarHostState
) {
val scope = rememberCoroutineScope()
Button(
scope.launch {
snackbarHostState.showSnackbar("Saved")
}
}
) {
Text("Save")
}
}The scope is cancelled when the composable leaves the composition.
LaunchedEffect vs rememberCoroutineScope
LaunchedEffect | rememberCoroutineScope |
|---|---|
| Starts automatically | Starts from an event |
| Controlled by keys | Controlled manually |
| Good for initial loading | Good for clicks |
| Coroutine created by the effect | Provides a scope for launch |
Screen appears → LaunchedEffect
User clicks → rememberCoroutineScope---
8. snapshotFlow
snapshotFlow converts Compose State reads into a Kotlin Flow.
val listState = rememberLazyListState()
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex
}
.distinctUntilChanged()
.filter { it > 0 }
.collect {
analytics.logScrolledPastFirstItem()
}
}flowchart LR
CS["Compose State"] --> SF["snapshotFlow"]
SF --> F["Kotlin Flow"]
F --> O["Collect operation"]Use it when Flow operators such as these are helpful:
* filter
* map
* debounce
* distinctUntilChanged
snapshotFlow observes the Compose State values read inside its block and emits when their result changes.
---
9. Lifecycle-Aware Collection
A Compose screen should not keep collecting UI data unnecessarily while it is not active.
For Android UI, use:val state by viewModel.uiState.collectAsStateWithLifecycle()stateDiagram-v2
[*] --> Stopped
Stopped --> Collecting: Lifecycle active
Collecting --> Stopped: Lifecycle inactive
Stopped --> Collecting: Active againIt:
* Collects when the lifecycle is active
* Pauses collection when inactive
* Converts Flow into Compose State
* Recombines the latest value with the UI
For platform-independent Compose code, `collectAsState()` can be used. On Android screens, lifecycle-aware collection is normally preferred. [Android: State and Compose](https://developer.android.com/develop/ui/compose/state)---
10. Quick Effect Selection
| Requirement | Recommended API | Purpose | Example |
|---|---|---|---|
| Run suspend work when screen appears | LaunchedEffect | Launches a coroutine when the composable first enters the composition. | Load data when a screen opens. |
| Restart work when an input changes | LaunchedEffect(key) | Cancels the previous coroutine and restarts it whenever the key changes. | Fetch new data when userId changes. |
| Register something and clean it up | DisposableEffect | Performs setup when entering composition and cleanup when leaving. | Register/unregister a BroadcastReceiver or lifecycle observer. |
| Publish Compose State to non-Compose code | SideEffect | Executes after every successful recomposition to synchronize with external objects. | Update analytics user properties or a third-party SDK. |
| Convert async data to Compose State | produceState | Converts callback-, coroutine-, or async-based data into State<T>. | Load an image or observe a callback API. |
| Launch coroutine after a click | rememberCoroutineScope | Provides a coroutine scope tied to the composable lifecycle for event handlers. | Show a Snackbar after a button click. |
| Convert Compose State to Flow | snapshotFlow | Converts Compose state reads into a Kotlin Flow. | Observe scroll position changes for analytics. |
Collect ViewModel Flow safely | collectAsStateWithLifecycle() | Collects a Flow in a lifecycle-aware manner and exposes it as Compose State. | Observe StateFlow<UiState> from a ViewModel. |
---
Part 2: Performance and Recomposition Optimization
11. Stable and Immutable Models
Compose can skip a composable more easily when its parameters are stable.
Prefer immutable models:
@Immutable
data class ProductUiModel(
val id: String,
val name: String,
val price: Double
)Avoid silently changing normal mutable properties:
data class Product(
var name: String // Harder to observe safely
)Prefer creating a new object:
product = product.copy(name = "New name")Important rule
Do not add @Stable or @Immutable only to suppress performance warnings. The class must actually follow that contract.
---
12. Recomposition Scope
Compose tracks State reads according to where they happen.
Broad State read
@Composable
fun ProductScreen(viewModel: ProductViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header()
ProductList(state.products)
Footer()
}
}When state changes, a larger scope may need evaluation.
Smaller composables
@Composable
fun ProductScreen(state: ProductUiState) {
Header()
ProductList(state.products)
Footer(state.total)
}Small, focused composables give Compose better opportunities to skip unchanged UI.
flowchart TD
S["State changes"] --> A["Affected scope"]
A --> R["Recompose affected UI"]
A --> K["Skip unchanged UI"]---
13. Stable Keys
In lazy lists, provide a stable unique key.
LazyColumn {
items(
items = products,
key = { product -> product.id }
) { product ->
ProductItem(product)
}
}Without a key, Compose mainly identifies an item by its position.
If an item is inserted at the top, many positions change.
flowchart LR
C["List changes"] --> K["Stable ID key"]
K --> M["Compose matches existing items"]
M --> L["Less unnecessary work"]Keys also help remembered item state move with the correct item.
Avoid:
key = { product.name } // May not be uniquePrefer:
key = { product.id }---
14. Lambda Stability
Creating or passing changing lambdas can make skipping harder in some situations.
Prefer a stable function reference when practical:
ProductList(
products = state.products,
)If a wrapper captures changing values, ensure the value is truly needed:
ProductItem(
viewModel.onProductClick(product.id)
}
)For callbacks used inside a long-running effect, use `rememberUpdatedState` to access the latest callback without restarting the effect:
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(3_000)
currentOnTimeout()
}Do not add remember around every lambda automatically. Optimize after measuring a real recomposition problem.
---
15. Deferred State Reads
Read State as close as possible to where it is used.
Early read
val offset = scrollState.value
Title(offset = offset)The parent reads the State and may recompose frequently.
Deferred read
Title(
offsetProvider = { scrollState.value }
)@Composable
fun Title(offsetProvider: () -> Int) {
Text(
text = "Title",
modifier = Modifier.offset {
IntOffset(0, offsetProvider())
}
)
}The value is read later in the layout phase rather than during the parent’s composition.
flowchart LR
S["Fast-changing State"] --> D["Defer read"]
D --> L["Layout or draw phase"]
L --> R["Avoid wider recomposition"]Deferred reads help Compose rerun the minimum required work.
---
16. derivedStateOf
Use derivedStateOf when input changes frequently but the UI only cares when a calculated result changes.
val listState = rememberLazyListState()
val showTopButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}The list position may change repeatedly, but showTopButton changes only between true and false.
flowchart LR
P["Scroll position changes often"] --> D["derivedStateOf"]
D --> B["Boolean changes rarely"]
B --> UI["Recompose only when needed"]Do not use it for every basic calculation because it also has a cost.
---
17. Cache Expensive Calculations
Avoid repeating expensive work during every recomposition.
Avoid
val sortedProducts = products.sortedBy { it.name }Better
val sortedProducts = remember(products) {
products.sortedBy { it.name }
}The calculation runs again only when products changes.
Do not use remember to hide incorrect logic. Use it for genuinely expensive calculations or objects that should remain stable.
---
18. Avoid Backward State Writes
Do not read a State and then change the same State during composition.
Wrong
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
Text("$count")
count++ // Changes state during composition
}This can cause repeated or endless recomposition.
State should normally change from:
* User callbacks
* Effect APIs
* Observable data sources
---
19. Tracing and Measuring Recomposition
Do not optimize based only on guesswork.
Useful tools include:
Layout Inspector
Use recomposition counts to identify composables that recompose or skip frequently.
Compose compiler reports
They help inspect:
* Stable and unstable classes
* Restartable composables
* Skippable composables
System tracing and Perfetto
Use tracing to find:
* Slow composition
* Expensive layout
* Long drawing work
* Frame delays and jank
Macrobenchmark
Use it to measure realistic application performance, scrolling and startup in a release-like build.
flowchart LR
M["Measure"] --> F["Find slow scope"]
F --> O["Optimize"]
O --> M2["Measure again"]Always measure performance in a release build because debug Compose performance is not representative. Android: Compose performance
---
Common Mistakes
1. Calling suspend work directly in the composable
Use LaunchedEffect or trigger work through a user callback.
2. Using the wrong Effect key
LaunchedEffect(Unit) {
loadProduct(productId)
}This will not restart when productId changes.
Use:
LaunchedEffect(productId) {
loadProduct(productId)
}3. Forgetting cleanup
If you register a listener, observer or receiver, remove it in `DisposableEffect.onDispose`.
4. Using SideEffect for API calls
SideEffect runs after every successful recomposition. It is not designed for one-time loading.
5. Using list position as identity
Use a stable business ID for lazy-list keys.
6. Passing mutable models
Prefer immutable UI models and replace objects instead of changing hidden mutable properties.
7. Using derivedStateOf everywhere
Use it only when a rapidly changing input produces a result that changes less often.
8. Optimizing without measurement
First locate the actual problem using recomposition counts, tracing or benchmarks.
---
Interview Questions and Answers
Q1. Why should composables be side-effect free?
Because composables may run repeatedly, be skipped or be cancelled. Direct side effects could therefore run unpredictably.
Q2. When should we use LaunchedEffect?
Use it for suspend work that should start when an effect enters composition and restart when its key changes.
Q3. When should we use DisposableEffect?
Use it when initialization requires matching cleanup, such as registering and unregistering a listener.
Q4. What is the difference between SideEffect and LaunchedEffect?
SideEffect runs after every successful recomposition and cannot directly call suspend functions. LaunchedEffect launches a lifecycle-aware coroutine controlled by keys.
Q5. What is the difference between LaunchedEffect and rememberCoroutineScope?
LaunchedEffect starts automatically from composition. rememberCoroutineScope is used to start work from an event such as a button click.
Q6. What does produceState do?
It converts asynchronous or external data into Compose State and manages its coroutine lifecycle.
Q7. What does snapshotFlow do?
It observes Compose State reads and converts their changes into a Kotlin Flow.
Q8. Why use collectAsStateWithLifecycle()?
It collects Flow according to the Android lifecycle, avoiding unnecessary collection while the UI is inactive.
Q9. How do stable models improve performance?
They help Compose determine that unchanged parameters are safe to skip during recomposition.
Q10. Why provide keys in LazyColumn?
Stable keys preserve item identity when the list changes, helping Compose reuse the correct UI and remembered state.
Q11. What is a deferred State read?
It means reading State closer to the phase where it is needed, which can avoid recomposing a larger parent scope.
Q12. How do you find unnecessary recomposition?
Use Layout Inspector recomposition counts, compiler reports, tracing, Perfetto and benchmarks.
---
Final Interview Answer
> Compose side effects should run through lifecycle-aware Effect APIs. LaunchedEffect runs suspend work, DisposableEffect manages registration and cleanup, SideEffect publishes State to non-Compose code, produceState converts asynchronous data to Compose State, rememberCoroutineScope launches work from UI events, and snapshotFlow converts Compose State into Flow. For performance, I use immutable and stable models, small recomposition scopes, stable list keys, deferred State reads and derivedStateOf where appropriate. I verify problems using recomposition counts, compiler reports, tracing and benchmarks before optimizing.