Compose Side Effects Are Not Edge Cases — They Are the Whole Control Plane
Foreword
In previous articles, we repeatedly emphasized: Composable functions should be pure. Their only responsibility is to receive state and render UI.
But in the real world, we need to make network requests, show a Toast, listen to sensor changes, or initialize a video player. These operations go beyond the scope of "rendering UI," and we call them "Side Effects."
As senior developers, we are used to handling this logic in onCreate, onResume, and onDestroy. However, in Compose's environment of "arbitrary" recomposition, if handled improperly, your app could suffer from infinite requests, memory leaks, or even bizarre crashes.
Today, we will systematically master the family of Side Effect APIs provided by Compose.
1. Why Can't Business Logic Be Written Directly Inside a Composable Function Body?
Look at this typical wrong example:
@Composable
fun UserProfile(userId: String) {
val user = loadUserFromApi(userId) // ❌ Fatal error!
Text("Username: ${user.name}")
}
Reason: Recomposition can happen 60 times per second for any reason (like an animation or a click). If you write it directly in the function body, it means your API request will also be sent 60 times per second.
We need a mechanism that allows logic to run "only when necessary and in a controlled manner."
2. Core Side Effect APIs: The Right Tool for the Job
1. LaunchedEffect: A Safe Harbor for Coroutines
When you need to launch a coroutine upon entering a screen (like loading data or a countdown), this is the first choice.
- Characteristics: Bound to the Composable's lifecycle. Launches when entering composition and automatically cancels when leaving.
- Key Mechanism: It receives a
key. It will only restart when thekeychanges.
LaunchedEffect(userId) { // Only re-fetches when userId changes
viewModel.loadData(userId)
}
2. rememberCoroutineScope: Event-Driven Coroutines
If you want to launch a coroutine when a button is clicked (like showing a SnackBar), you cannot use LaunchedEffect (because it must be declared inside the Composable).
- Usage: Get a
Scopebound to the current Composable, then launch manually.
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { /* Async operation */ }
}) { ... }
3. DisposableEffect: Cleanup from Start to Finish
Need to register a listener, initialize a third-party SDK, and unregister when the UI is destroyed? Choose this.
- Mandatory Requirement: Must end with
onDispose { ... }.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { ... }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer) // Guarantees no memory leak
}
}
4. SideEffect: Synchronizing with Non-Compose Code
If you need to synchronize Compose's internal state with external code (like a renderer maintained by C++), you can use this. It executes after every successful recomposition.
3. Advanced Technique: Handling Changes During a "Long Run"
rememberUpdatedState: Preventing Stale Closures
Imagine a scenario: You start a 10-second timer inside a LaunchedEffect. Within those 10 seconds, the parameters passed in from outside change. By default, since the coroutine hasn't finished, it still holds the old value from 10 seconds ago.
- Solution: Use
rememberUpdatedState. It ensures the coroutine internally always references the latest value without needing to restart the coroutine.
4. Architectural Advice for Developers
- Push Side Effects Upwards, Even into the ViewModel: In MVI or MVVM architectures, complex business logic (network, DB) should be handled in the ViewModel. Compose's Side Effect APIs are more for handling "UI-related" system interactions (like listening for the back button, handling lifecycle callbacks).
- Choose Keys Carefully:
LaunchedEffect(true)means it runs only on the first entry into composition. This is useful for handling "tracking upon page entry." - Never Forget
onDispose: As a senior developer, vigilance against memory leaks should be instinctive. AnywhereDisposableEffectis used, double-check that the cleanup logic forms a closed loop.
Conclusion
Once you master the Side Effect APIs, you truly gain "control" over Compose. You are no longer a spectator passively waiting for recomposition, but a director capable of precisely scheduling logic.
Next article we will explore: CompositionLocal: The Pros, Cons, and Best Practices of Implicit Data Passing. If you found this helpful, feel free to like and follow. We evolve on code and delve deep into principles.
Top 1 of 3 from juejin.cn, machine-translated. The original thread is authoritative.
TypeScript is now pretty much standard for medium-to-large projects. The benefits of a type system are especially obvious in multi-person collaboration. Our team hit a lot of pitfalls when we first migrated to TS, especially compatibility issues with type definitions for third-party libraries. I'd recommend using strict mode directly for new projects; for older projects, you can migrate gradually, using 'any' as a transition and then slowly filling in the types.
Haha, buddy, did you just wander over from the frontend channel next door? But the TypeScript migration journey you mentioned is exactly like the pain we went through migrating from Java/XML to Kotlin/Compose! Although the languages are different, the engineering mindset around type safety, strong type constraints, and multi-person collaboration is universal. In KMP projects, what we're chasing is that same kind of cross-platform type consistency. Feel free to follow my Compose series to see how the mobile side's 'type system' plays out! 🤝
Tech folks just never run out of things to worry about [snicker]