跪拜 Guibai
← All articles
Android · Android Jetpack · Architecture

Compose Side Effects Are Not Edge Cases — They Are the Whole Control Plane

By 杉氧 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Compose's declarative model breaks every imperative lifecycle assumption Android developers rely on. Misplacing a network call or a listener registration inside a Composable body produces failures that are silent, multiplicative, and hard to reproduce — the Side Effect APIs are the only safe surface for bridging Compose's recomposition engine with the real world.

Summary

Writing business logic directly inside a Composable function body is a fast path to infinite network requests and memory leaks, because recomposition can fire 60 times per second for unrelated reasons. The Side Effect API family solves this by binding execution to composition lifecycles and explicit key changes. LaunchedEffect runs coroutines scoped to a Composable's presence in the tree and restarts only when its key changes; DisposableEffect mandates cleanup via onDispose; rememberCoroutineScope hands control to event handlers for user-driven coroutine launches; and SideEffect synchronizes state to non-Compose code after every successful recomposition.

A common trap is stale closures inside long-running effects. rememberUpdatedState keeps a mutable reference that always points to the latest value without restarting the effect, so a 10-second timer inside LaunchedEffect sees fresh parameters as they arrive. The architectural advice is equally sharp: push business logic into the ViewModel and reserve Compose Side Effects for UI-level system interactions like lifecycle observation and back-press handling.

Choosing the right key — including the deliberate use of LaunchedEffect(true) for one-shot entry tracking — and never skipping onDispose are the two habits that separate leak-free Compose code from production incidents.

Takeaways
Recomposition can fire 60 times per second, so any side-effecting code placed directly in a Composable body will execute at that rate.
LaunchedEffect is bound to the Composable's composition lifecycle: it launches on entry, cancels on exit, and restarts only when its key changes.
rememberCoroutineScope provides a coroutine scope for event-driven launches, such as button clicks, where LaunchedEffect cannot be used.
DisposableEffect requires an onDispose block and is the correct tool for registering and unregistering listeners or initializing third-party SDKs.
SideEffect runs after every successful recomposition and is meant for synchronizing Compose state with external, non-Compose code.
rememberUpdatedState prevents stale closures in long-running effects by holding a reference that always points to the latest value without restarting the effect.
LaunchedEffect(true) is a deliberate pattern for running a side effect exactly once, on first composition — useful for page-entry analytics.
Complex business logic belongs in the ViewModel; Compose Side Effect APIs should handle UI-level system interactions like lifecycle callbacks and back-press handling.
Conclusions

Compose's Side Effect APIs are not a workaround for impurity — they are the declarative equivalent of the Activity/Fragment lifecycle methods, but with finer-grained key-based restart semantics that the old model never had.

The key parameter on LaunchedEffect is a scheduling primitive, not just a cache-busting trick. Changing the key restarts the entire coroutine scope, which makes it a deliberate control point for when logic should re-execute.

rememberUpdatedState solves a problem that is invisible in imperative code: long-lived coroutines inside Composable functions would otherwise capture stale values because recomposition creates new lambda instances without restarting the effect.

The architectural advice to push side effects into the ViewModel reveals a tension in Compose's design — it can host business logic, but the framework's own guidance is to keep the Composable layer thin and UI-focused.

DisposableEffect's mandatory onDispose is a language-level guardrail against memory leaks that Android's traditional lifecycle callbacks never enforced, making cleanup failures a compile-time or review-time catch rather than a runtime surprise.

Concepts & terms
Side Effect
Any operation inside a Composable that escapes the scope of rendering UI from state — network calls, database writes, Toast display, sensor registration, or third-party SDK initialization.
LaunchedEffect
A Compose effect that launches a coroutine when it enters composition, cancels it when it leaves, and restarts it when its key parameter changes.
rememberCoroutineScope
A Composable function that returns a CoroutineScope bound to the composition's lifecycle, used to launch coroutines from event handlers like button clicks.
DisposableEffect
A Compose effect that runs a setup block on entering composition and requires an onDispose cleanup block that executes when the effect leaves composition, preventing resource leaks.
SideEffect (function)
A Compose function that executes a block after every successful recomposition, intended for synchronizing Compose state with non-Compose code such as external renderers.
rememberUpdatedState
A Compose utility that holds a mutable reference to a value that updates on every recomposition, allowing long-running effects to read the latest value without restarting.
Recomposition
The process by which Compose re-invokes Composable functions when their input state changes, potentially at high frequency (up to 60 times per second during animations).
From the discussion

A frontend migration anecdote about TypeScript's strict mode and third-party type definitions gets cross-mapped onto the Kotlin/Compose ecosystem. The shared engineering concern is type safety and collaborative discipline across platforms. A third voice undercuts the whole exchange with a wry observation about the endless anxieties of technical work.

TypeScript's strict mode and gradual migration via 'any' are practical strategies for adopting type safety in collaborative projects.
Third-party type definition compatibility is a recurring pain point during TypeScript adoption.
The cultural and technical friction of migrating from Java/XML to Kotlin/Compose mirrors the frontend TypeScript migration experience.
Cross-platform type consistency is a shared goal between web (TypeScript) and mobile (KMP/Compose) ecosystems.
The entire exchange is framed as a symptom of technologists' perpetual, self-inflicted anxiety.
Featured comments
向新出发叭 1 likes

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! 🤝

向新出发叭  → 杉氧  · 1 likes

Tech folks just never run out of things to worry about [snicker]

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗