Mastering Side-Effects in Composable
From Zero to Hero in Jetpack Compose for 2025 | Part 2
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:
- 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. - When you increment
count.value
by clicking the button, the state changes. - The Compose runtime tracks that
count.value
is used in theCounter()
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:
- It only updates the parts of the UI that depend on the changed state.
- 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, whereasrememberCoroutineScope
provides a reusableCoroutineScope
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, and
updatedCount.value
always reflects the latest value ofcount
.
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 toDisposableEffect
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) toDisposableEffect
. 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:
- It launches a coroutine in a Composable to produce a value for a
State
. - 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:
initialValue
: Sets the initial value of the state before the coroutine completes.- Automatically cancels the coroutine when the Composable is no longer active.
- 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:
- A derived state is computed based on other state(s).
- 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.