Blog Infos
Author
Published
Topics
, , , ,
Published

Unsplash@himalaya1788

The Jetpack Compose ecosystem has grown exponentially in recent years, and it’s now widely adopted for building production-level UIs in Android applications. Now, we can say that Jetpack Compose is the future of Android UI development.One of the biggest advantages of Compose is its declarative approach — it allows developers to describe what the UI should display, while the framework handles how the UI should update when the underlying state changes. This model shifts the focus from imperative UI logic to a more intuitive and reactive way of thinking.However, while declarative UIs offer many benefits, properly managing side effects becomes essential. Composable functions can be recomposed for various reasons, such as changes in state or parameters, and if side effects aren’t handled carefully, apps can behave unpredictably.In this article, you’ll explore the side-effect handling APIs that Jetpack Compose provides by default. You’ll also examine their internal workflows to better understand how Compose manages these operations under the hood.

What is a Side-Effect?

A side-effect refers to a change in application state that occurs outside the scope of a composable function. In Jetpack Compose, composable functions can be re-executed frequently and unpredictably due to recomposition triggered by state changes, parameter updates, or other events. Therefore, you can’t assume that a composable will be invoked only once.In other words, directly calling business logic, such as fetching data from a network or querying a database inside a composable function is risky. Because of potential recompositions, these operations might run multiple times unintentionally, leading to bugs or performance issues.To address this, Jetpack Compose provides a set of APIs specifically designed for managing side effects in a safe and controlled manner. These include LaunchedEffectDisposableEffectSideEffectrememberCoroutineScope, among others. In this article, you’ll focus on the three most commonly used handlers—LaunchedEffectDisposableEffect, and SideEffect—and take a closer look at their internal implementation to better understand how they work under the hood.

LaunchedEffect

LaunchedEffect is normally one of the most frequently used side-effect handling APIs in Jetpack Compose. It allows you to launch coroutines in a composable lifecycle-aware manner (not the Android lifecycles), and it ensures that the provided block of code is not re-executed unless one of the specified key parameters changes.This behavior makes LaunchedEffect especially useful for executing a one-off event tied to a specific state, such as showing a toast or snackbar, logging events, or triggering business logic as you can see in the example code from the Now in Android project:

val snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
// If user is not connected to the internet show a snack bar to inform them.
val notConnectedMessage = stringResource(R.string.not_connected)
LaunchedEffect(isOffline) {
if (isOffline) {
snackbarHostState.showSnackbar(
message = notConnectedMessage,
duration = Indefinite,
)
}
}

One important thing to keep in mind is that LaunchedEffect creates a new coroutine scope under the hood. This means it’s primarily designed for executing coroutine-based tasks within the scope of a composable and will automatically cancel its coroutine when the composable leaves the composition.

So, LaunchedEffect is best suited for coroutine-related operations, such as data fetching, delayed effects, or event handling rather than simply executing non-suspending functions. Now, let’s take a look under the hood to better understand how LaunchedEffect works internally.

@Composable
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
internal class LaunchedEffectImpl(
parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
private val scope = CoroutineScope(parentCoroutineContext)
private var job: Job? = null
override fun onRemembered() {
// This should never happen but is left here for safety
job?.cancel("Old job was still running!")
job = scope.launch(block = task)
}
override fun onForgotten() {
job?.cancel(LeftCompositionCancellationException())
job = null
}
override fun onAbandoned() {
job?.cancel(LeftCompositionCancellationException())
job = null
}
}

As you’ve seen in the internal implementation of LaunchedEffect, it creates LaunchedEffectImpl and remembers it on the in-memory with the given key values as a parameter to re-creating the LaunchedEffectImpl instance whenever the key changes.

If you take a look at the internal LaunchedEffectImpl class, you’ll see it implements RememberObserver and creates a new CoroutineScope initially. The provided lambda is then launched within this scope as the composable enters the composition phase. When the composable leaves the composition, the coroutine scope is automatically canceled, ensuring resources are cleaned up properly and avoiding potential memory leaks or performance issues.

That said, if your task doesn’t involve any coroutine-related operations and simply needs to re-execute when the key changes, using LaunchedEffect might be slightly overkill. While the overhead of creating a coroutine scope is generally minimal, it’s still unnecessary in cases where no coroutine is actually used. In such scenarios, you can consider using a lighter side-effect handler library (RememberedEffect) more appropriate for non-suspending tasks.

Another common misconception is that LaunchedEffect is aware of the Android lifecycle—but that’s not true. As seen in the internal implementation, LaunchedEffect is entirely scoped to the Jetpack Compose composition lifecycle and has no direct connection to the Android lifecycle.

In other words, it doesn’t inherently know anything about activities, fragments, or lifecycle events like onStop() or onDestroy(). This means that if you launch a coroutine inside LaunchedEffect and the Android component (e.g. an activity) is stopped or destroyed without the composable leaving the composition, the coroutine may continue running unless it’s explicitly tied to the Android lifecycle.

DisposableEffect

DisposableEffect is another side-effect handling API provided by the Jetpack Compose runtime. It allows you to perform setup and clean-up logic in sync with the composable’s lifecycle. Unlike LaunchedEffect, it offers a DisposableEffectScope as the receiver, enabling you to define a clean-up block that runs automatically when the composable leaves the composition. This makes it ideal for managing external resources like listeners, callbacks, or broadcast receivers that need explicit teardown.

val lifecycleOwner = LocalLifecycleOwner.current
// If `lifecycleOwner` changes, dispose and reset the effect
DisposableEffect(lifecycleOwner) {
// Create an observer that triggers our remembered callbacks
// for sending analytics events
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// do something
} else if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
// do something
}
}
// Add the observer to the lifecycle
lifecycleOwner.lifecycle.addObserver(observer)
// When the effect leaves the Composition, remove the observer
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}

The example above uses a DisposableEffect to register a LifecycleEventObserver to the lifecycleOwner, allowing it to observe lifecycle changes and execute specific logic based on the current state. The observer is safely removed within the onDispose block, ensuring proper cleanup when the composable leaves the composition. Now, let’s take a look under the hood to better understand how DisposableEffect works internally.

@Composable
fun DisposableEffect(
key1: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult
) {
remember(key1) { DisposableEffectImpl(effect) }
}
private class DisposableEffectImpl(
private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
private var onDispose: DisposableEffectResult? = null
override fun onRemembered() {
onDispose = InternalDisposableEffectScope.effect()
}
override fun onForgotten() {
onDispose?.dispose()
onDispose = null
}
override fun onAbandoned() {
// Nothing to do as [onRemembered] was not called.
}
}
class DisposableEffectScope {
inline fun onDispose(
crossinline onDisposeEffect: () -> Unit
): DisposableEffectResult = object : DisposableEffectResult {
override fun dispose() {
onDisposeEffect()
}
}
}

As seen in the internal implementation of DisposableEffect, it creates a DisposableEffectImpl instance and stores it in memory using the provided key(s). Whenever the key changes, a new instance of DisposableEffectImpl is created, allowing the effect to be re-executed accordingly.

The DisposableEffectImpl class implements RememberObserver and initially creates a DisposableEffectResult. The effect lambda is launched within the DisposableEffectScope when the composable enters the composition phase. Upon leaving the composition, the onDispose function of the DisposableEffectResult is automatically called, ensuring proper resource cleanup and preventing memory leaks or performance issues before the composable is fully removed from the composition.

SideEffect

The SideEffect API in Jetpack Compose is used to safely notify external, non-Compose-managed objects of state changes that occur within a composable. It ensures that the effect runs after a successful recomposition, making it ideal for triggering side-effects that depend on the final, stable state of the UI.

Using SideEffect avoids the risk of performing operations during recomposition phases that may later be discarded, which can happen if you write the effect directly in a composable without this safeguard. This makes SideEffect essential when you need to synchronize Compose state with external systems like logging tools, analytics, or imperative UI components, as you can see in the example below:

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// On every successful composition, update FirebaseAnalytics with
// the userType from the current User, ensuring that future analytics
// events have this metadata attached
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
view raw SideEffect.kt hosted with ❤ by GitHub

Now, let’s explore how SideEffect API works under the hood.

@Composable
fun SideEffect(
effect: () -> Unit
) {
currentComposer.recordSideEffect(effect)
}
/** Schedule a side effect to run when we apply composition changes. */
override fun recordSideEffect(effect: () -> Unit) {
changeListWriter.sideEffect(effect)
}
view raw SideEffect.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

At first glance, the code above may appear simple yet difficult to fully understand and that’s completely natural. This is because the SideEffect API is closely tied to low-level Compose runtime internals, particularly the ChangeList, which tracks and manages the list of state-driven changes used to update the rendered UI.

According to internal comments in the Compose source code, the SideEffect API is represented like the below:

Schedule effect to run when the current composition completes successfully and applies changes. SideEffect can be used to apply side effects to objects managed by the composition that are not backed by snapshots so as not to leave those objects in an inconsistent state if the current composition operation fails.

effect will always be run on the composition’s apply dispatcher and appliers are never run concurrent with themselves, one another, applying changes to the composition tree, or running RememberObserver event callbacks. SideEffects are always run after RememberObserver event callbacks.

So, the SideEffect API runs after every successful recomposition.

Conclusion

In this article, you explored three primary side-effect handling APIs commonly used in Jetpack Compose. Due to the nature of declarative UI, state influences many aspects of runtime behavior, making proper side-effect handling essential for ensuring correct and predictable task execution.This topic initially has been covered in Dove Letter, a private repository offering daily insights on Android and Kotlin, including topics like Compose, architecture, industry interview questions, and practical code tips. In just 37 weeks since its launch, Dove Letter has surpassed 700 individual subscribers and 20 business/lifetime subscribers. If you’re eager to deepen your knowledge of Android, Kotlin, and Compose, be sure to check out ‘Learn Kotlin and Android With Dove Letter’.

This article was previously published on proandroiddev.com.

Menu