Mastering Side-Effects in Composable

From Zero to Hero in Jetpack Compose for 2025 | Part 2

Dhananjay Trivedi
7 min read1 day ago

In Jetpack Compose, side effects play a crucial role in interacting with components outside the Compose framework, such as managing state, handling lifecycle changes, and cleaning up resources.

LaunchedEffect

LaunchedEffect is a composable function designed to launch a coroutine tied to the composition's lifecycle. It is particularly useful for one-time operations or operations triggered by changes to a key.

@Composable
fun LaunchedEffectComposable() {
val categoryState = remember { mutableStateOf(emptyList<String>()) }

// Launched Effect with Unit passed as its key
LaunchedEffect(key1 = Unit) {
categoryState.value = fetchCategories()
}

LazyColumn {
items(categoryState.value) { item ->
Text(text = item)
}
}
}

fun fetchCategories(): List<String> {
// Assuming network call
return listOf("One", "Two", "Three")
}
  • In the above example, LaunchedEffect is called only once because we have passed Unit as the key.
  • The fetchCategories() function will not block the composition.
  • Jetpack Compose internally keeps track of all state objects and their values
  • When the categoryState.value is updated inside LaunchedEffect, Compose is notified of this change.
  • Compose then triggers a recomposition of any composable functions that read this state.
  • In our case, ListComposable and its children (including LazyColumn and items) will recompose.
  • Remember, LaunchedEffect is called during the Initial Composition or Key update.
@Composable
fun Counter() {
var count = remember { mutableStateOf(value = 0) }
var key = count.value % 3 == 0 // Key is updated

// LaunchedEffect is called when the key is updated
LaunchedEffect(key1 = key) {
Log.d(tag = "Counter", msg = "Current count: ${count.value}")
}

// We incrememnt the count which will trigger the recomposition
Button(onClick = { count.value++ }) {
Text(text = "Increment count")
}
}

Why is the whole composable recomposed?

The Counter() composable is recomposed because of how state-driven recomposition works in Jetpack Compose:

  1. The count variable is a state (mutableStateOf), and any change to its value triggers recomposition of all composables that read or depend on this state.
  2. When you increment count.value by clicking the button, the state changes.
  3. The Compose runtime tracks that count.value is used in the Counter() function, so it schedules this function for recomposition.

Does recomposition mean everything is recreated?

No. Even though the Counter() function is called again, Jetpack Compose optimizes what happens during recomposition:

  1. It only updates the parts of the UI that depend on the changed state.
  2. It skips expensive work (e.g., remember prevents certain values or objects from being recreated during recomposition).

Launched Effect With Config Changes

@Composable
fun LaunchEffectComposable() {
val counter = remember { mutableStateOf(value = 0) }

LaunchedEffect(key1 = Unit) {
Log.d("LaunchEffectComposable", "Started...")
try {
for (i in 1..10) {
counter.value++
delay(timeMillis = 1000) // Simulates some periodic work
}
} catch (e: Exception) {
Log.d("LaunchEffectComposable", "Exception: ${e.message.toString()}")
}
}

var text = "Counter is running ${counter.value}"
if (counter.value == 10) {
text = "Counter stopped"
}

Text(text = text)
}
  • On Config changes LaunchedEffect gets recalled as it is a Composable function and the existing coroutine inside it gets disposed of to avoid memory leak.
  • On every counter increment the whole `LaunchEffectComposable` gets called.
  • LaunchedEffect has Unit key hence it is not recalled.
  • The launched effect cannot be called from any block of code, example onClick of a button we cannot call it as it is a composable.
  • In LaunchedEffect, the Coroutine Scope is given by the composable hence we cannot manage its scope and the coroutines called inside it. Solution?

rememberCourtineScope

@Composable
fun RememberCoroutineScopeExample() {
val coroutineScope = rememberCoroutineScope()

Button(onClick = {
coroutineScope.launch {
// Perform some background work
delay(1000)
Log.d("Example", "Button clicked!")
}
}) {
Text("Click Me")
}
}

Lifecycle Awareness:

  • The coroutine launched via the scope will be automatically canceled when the composable is removed from the composition, preventing memory leaks, similar to LaunchEffect.

Flexibility:

  • Unlike LaunchedEffect, you can manage when and how coroutines are launched without being tied to recomposition triggers.

Use Cases:

  • Ideal for handling one-off tasks like button clicks or user interactions.
  • For example, launching background tasks like network requests or animations that are not tied to the composition lifecycle.

Difference from LaunchedEffect:

  • LaunchedEffect is lifecycle-aware but recomposes with key changes, whereas rememberCoroutineScope provides a reusable CoroutineScope you control manually.

rememberUpdatedState in Jetpack Compose

rememberUpdatedState is a function in Jetpack Compose that ensures the latest value of a state is available to a long-running side-effect or coroutine, even if the value changes during its execution.

Purpose

  • Allows side-effects (e.g., LaunchedEffect, DisposableEffect, coroutines) to always use the latest value of a state without re-triggering the effect or restarting the coroutine.

Why It’s Useful:

  • Long-running operations like animations, event listeners, or timers may depend on state values. If the state changes, restarting these operations can be inefficient or incorrect. rememberUpdatedState prevents this issue.

How It Works:

  • It “remembers” the updated value of a state without causing recompositions or restarts of the side-effect block where it is used.

Common Use Cases:

  • Handling callbacks or lambdas with the latest state.
  • Ensuring long-running coroutines or effects stay up-to-date with state changes.
@Composable
fun CounterExampleWithoutRememberUpdatedState(count: Int) {
LaunchedEffect(Unit) {
while (true) {
delay(1000)
Log.d("Example", "Count is: $count") // Logs the old value if `count` changes
}
}
}
  • If count changes, this coroutine won't see the updated value unless re-triggered.
@Composable
fun CounterExampleWithRememberUpdatedState(count: Int) {
val updatedCount = rememberUpdatedState(count)

LaunchedEffect(Unit) {
while (true) {
delay(1000)
Log.d("Example", "Count is: ${updatedCount.value}") // Always logs the latest value
}
}
}
  • The coroutine stays running, andupdatedCount.value always reflects the latest value of count.

Disposable Side Effects

DisposableEffect is a side-effect handler in Jetpack Compose designed to perform an effect with a cleanup operation when the associated Composable leaves the Composition.

It is used for Side Effects that require cleanup to free resources or clean up like locationServices or mediaPlayer or binded event listeners.

DisposableEffect(key1, key2, ...) {
// Setup or start a resource
val resource = acquireResource()

onDispose {
// Cleanup or release the resource
releaseResource(resource)
}
}

Purpose:

  • Used for tasks that require resource management or cleanup, such as:
  • Registering and unregistering event listeners.
  • Starting and stopping services like location tracking.
  • Managing subscriptions or media players.

Lifecycle:

  • When the key passed to DisposableEffect changes or the associated Composable is removed from the Composition:
  • The cleanup block (onDispose) is executed.
  • The effect block is re-executed (if the Composable remains but the key changes).

How It Works:

  • You pass a key (or keys) to DisposableEffect. When the key changes or the Composable is no longer needed, DisposableEffect cleans up the previous effect before starting a new one.

Common Use Cases:

  • Managing system resources.
  • Setting up and tearing down observers, like BroadcastReceivers.
  • Implementing lifecycle-aware components.
@Composable
fun MediaPlayerExample() {
val context = LocalContext.current

DisposableEffect(Unit) {
val mediaPlayer = MediaPlayer.create(context, R.raw.sample_audio)
mediaPlayer.start()

onDispose {
mediaPlayer.stop()
mediaPlayer.release()
}
}
}

produceState

What is it?

  • produceState allows you to create a state in a Composable by running a suspendable block of code.
  • It is typically used to fetch data asynchronously or to create a state value that depends on some external coroutine-based operation.

How It Works:

  1. It launches a coroutine in a Composable to produce a value for a State.
  2. The produced state is recomposed whenever its value changes.
@Composable
fun ExampleComposable(): State<Type> {
return produceState(initialValue = defaultValue) {
// Coroutine block to calculate or fetch value
value = someSuspendFunction()
}
}
@Composable
fun UserData(userId: String): State<String> {
return produceState(initialValue = "Loading...", userId) {
value = try {
fetchUserData(userId) // Simulating a network call
} catch (e: Exception) {
"Error loading user data"
}
}
}

@Composable
fun DisplayUser(userId: String) {
val userData by UserData(userId)
Text(text = userData)
}

Explanation: The state (userData) starts as "Loading...", then gets updated when fetchUserData() completes.

Key Features:

  1. initialValue: Sets the initial value of the state before the coroutine completes.
  2. Automatically cancels the coroutine when the Composable is no longer active.
  3. Can take dependencies (keys) to restart the coroutine when they change.

derivedStateOf

What is it?

  • derivedStateOf creates a derived state that updates only when its dependencies change.
  • Useful for performance optimization by avoiding unnecessary recompositions.

How It Works:

  1. A derived state is computed based on other state(s).
  2. Recomposition happens only when the dependent state(s) change.
val derivedState = derivedStateOf { 
// Derived logic based on some state
"Derived value is ${baseState.value}"
}
@Composable
fun FilteredList(items: List<String>, query: String) {
val filteredItems by remember(items, query) {
derivedStateOf {
items.filter { it.contains(query, ignoreCase = true) }
}
}

LazyColumn {
items(filteredItems) { item ->
Text(text = item)
}
}
}

Explanation: The filtered list recomputes only when items or query changes, improving performance.

@Composable
fun TotalPrice(prices: List<Int>) {
val totalPrice by remember(prices) {
derivedStateOf { prices.sum() }
}

Text(text = "Total Price: $totalPrice")
}

Explanation: Recomposition for Text only happens when prices changes.

  • Use produceState for asynchronous, coroutine-backed state management.
  • Use derivedStateOf for lightweight, synchronous computations derived from existing state.
  • Together, they help build efficient and lifecycle-aware UIs in Jetpack Compose.

With that we come to the end of this story, stay tuned for Part 3.

--

--

Dhananjay Trivedi
Dhananjay Trivedi

No responses yet