DEV Community

Cover image for Navigating Effect Handlers and Side Effects in Jetpack Compose: A Practical Introduction
Audu Ephraim
Audu Ephraim

Posted on

Navigating Effect Handlers and Side Effects in Jetpack Compose: A Practical Introduction

What are Effects Handlers?

Effects handlers in Jetpack Compose are used to handle side effects in composable functions. To understand effects handlers better, we need to understand what side effects are.

A side effect is a situation where an asynchronous code that operates outside of the scope of a composable alters the state of the composable without taking into account the lifecycle of the composable.

For instance, initiating a network request where the response updates the composable state, triggering an animation sequence, or modifying composable properties in response to changes in state management are examples of actions that could lead to side effects.

These side effects occur as the composable interacts with asynchronous operations or external state changes that are not linked to user interface rendering.

To handle these side effects we make use of effects handlers. Let's delve into the different types of effects handlers on Jetpack Compose.

LaunchedEffect

It is a widely used effect handler in Jetpack Compose. It enables you to execute asynchronous operations, manage external data sources, and update the user interface based on the outcomes. Importantly, it ensures that these operations are tied to the lifecycle of the composable and are properly canceled when the composable is disposed of:

@Composable
fun MyScreen() {
   var data by remember { mutableStateOf("") }

   LaunchedEffect(key1 = data) {
       data = fetchDataFromApi() 
   }

   if (data.isNotEmpty()) {
       Text(text = data)
   } else {
       CircularProgressIndicator()
   }
}
Enter fullscreen mode Exit fullscreen mode

In this example, LaunchedEffect is used to fetch data from an API when the MyScreen composable is first composed. Here’s what’s going on:
LaunchedEffect launches a coroutine when MyScreen composable is drawn on the screen for the first time
The key1 = data parameter is used to tell the launch effect when to cancel and relaunch the coroutine. If the data variable changes launchedEffect will cancel the current coroutine and start a new one
Inside the launch effect, fetchDataFromApi() will be called which is a hypothetical suspend function that fetches data from an API. This could be anything that requires asynchronous computation like fetching data from a database or fetching over a network.
The result of fetchDataFromApi is then stored in the data variable this is observed by MyScreen composable.
If the data is not empty, a text composable is displayed with the data. Otherwise, a CircularProgressIndicator is shown indicating that data is still loading.
This is a simple example, but it demonstrates how launchedEffects can be used for handling asynchronous tasks in a composable lifecycle-aware manner. It ensures that the coroutine is cancelled when the composable is disposed of to avoid memory leaks. It also handles recomposition when the data changes ensuring the UI is up to date.

rememberCoroutinScope

With rememberCoroutineScope, we can get access to a coroutine scope that is life cycle aware within a composable. As soon as the composable leaves the composition, the coroutine scope is cancelled.

@Composable
fun myScreen() {
   val scope = rememberCoroutineScope()

   Button(onClick = {
       scope.launch {
           delay(1000L)
           println("hello world")
       }
   }) {

   }
}
Enter fullscreen mode Exit fullscreen mode

Inside myScreen we call rememberCoroutinScope. This creates a coroutine scope tied to the lifecycle of the composable.
We then define a button composable. The onClick parameter is a callback that is executed when the button is clicked.
Inside onClick we launch a new coroutine scope that we remembered earlier. This coroutine scope waits for 1 second and then prints “Hello World” to the console.

It’s important to note that this is typically used in callbacks, such as in an onClick event in our code. It’s not directly related to the actual composables. For instance, it could be used to initiate a network call when a button is clicked.
However, in an ideal scenario, this would be managed in the ViewModel, as you already have a scope defined in the ViewModel

rememberUpdatedState

rememberUpdatedState is a function that allows you to reference a value in an effect that shouldn't restart if the value changes. it is used to remember a mutable state and update its value on each recomposition of the remember state call:

@Composable
fun myScreen(
   onTimeout: () -> Unit
) {
   val updatedTimeout by rememberUpdatedState(newValue = onTimeout)
   LaunchedEffect(key1 = true){
       delay(3000L)
       updatedTimeout
   }
}
Enter fullscreen mode Exit fullscreen mode

myScreen composable takes a lambda onTimeout as a parameter. This lambda is called when a certain condition is met.
Inside myScreen, we call rememberUpdatedState. This function returns a mutable state object that holds a reference on onTimeout and updates the reference whenever onTimeout changes
Inside the launchedEffect we first delay for 3000 milliseconds. And then call updated timeout. This means that after 3000 milliseconds the ontimeout lambda passed to myScreen will be invoked
If onTimeout changes during recomposition of the composable updatedTimeout will always hold the latest version of onTimeout.

It is important to note that rememberUpdatedState should be used when parameters or values computed during composition are referenced by a long-lived lambda object or object expression.

DisposableEffect

Disposable effects are basically for managing side effects that require clean-up when the composable that created them is removed from composition or when its key changes. It is the ability to clean up these side effects when they are no longer needed:

@Composable
fun myScreen() {
   val lifecycleOwner = LocalLifecycleOwner.current
   DisposableEffect(key1 = lifecycleOwner){
       val observer = LifecycleEventObserver{ _, event ->
           if (event == Lifecycle.Event.ON_PAUSE){
               println("on pause caled")
           }
           lifecycleOwner.lifecycle.addObserver(observer)

           onDispose {
               lifecycleOwner.lifecycle.removeObserver(observer)
           }

       }
   }
}
Enter fullscreen mode Exit fullscreen mode

We define a composable myScreen
Inside myScreen, it fetches the current LifeCycleOwner using LocalLifeCycleOwner.current. This provides a way to interact with the lifecycle of the activity.
A DisposableEffect is used to perform side effects related to the lifeCycle. It has lifeCycleOwner as its key, meaning the effect will re-execute and dispose of/recreate resources if the lifeCycleOwner changes.
Within the Disposable effect an observer is created for lifecycle events (LifecycleEventObserver).
The observer is added to the lifecycleowner.lifecycle to receive lifecycle events.
The onDispose block is crucial for cleaning up resources when the effect is disposed of. It is responsible for removing the observer and ensuring that the observer is unregistered to avoid potential memory leaks and unwanted actions after the composable is no longer in the composition.

produceState

The purpose of this function is to produce some type of state that changes over time. It initiates a coroutine that is confined to the composition, allowing you to update the state’s content using the value property. This function is beneficial for handling data that changes periodically(similar to flow) or in response to user interactions:

@Composable
fun myScreen(countUpTo: Int): State<Int> {
   return produceState(initialValue = 0){
       while (value < countUpTo){
           delay(1000L)
           value++
       }
   }
}
Enter fullscreen mode Exit fullscreen mode

myScreen is a composable function that takes an integer countUpTo, and returns a state
Inside myScreen produce state is called with an initial value of 0. This function returns a state object that is remembered across recompositions
The produce state block contains a while loop that increments the value of the state by every 1 second as long the value is less that countUpTo
The value inside produceState refers to the current value of the state. You can read it and update it.
The updated value is returned as state from myScreen()

derivedStateOf

It is a function that allows you to create a new state function that is derived from one or more state objects. The primary goal of the derivedStateOf function is to create a derived state which is a state value computed based on other state values. Whenever the underlying state value changes the derived state changes.

@Composable
fun myScreen() {
   var counter by remember{
       mutableIntStateOf(0)
   }

   val counterText by remember{
       derivedStateOf {
           "the counter is $counter"
       }
   }

   Button(onClick = {counter++}) {
       Text(text = counterText)

   }
}
Enter fullscreen mode Exit fullscreen mode

In this example, counterText is a derived state that depends on the counter. Every time the counter changes counterText is automatically recalculated.
However, the Ui is only recomposed when counterText changes and not when counter changes.
It improves performance by reducing unnecessary recompositions.
It is often used in scenarios where you have a complex state that depends on one or more states.

snapShotFlow

With the snapshotflow function, think of it like you are a photographer capturing a series of snapshots of a scene that is constantly changing. You want to focus only on parts that have changed, rather than taking redundant photos of everything.
snapshotFlow enables you to do this with state values in your composables.
It creates a flow that emits snapshots of a state whenever it changes focusing only on the differences between previous and the current snapshots thereby optimizing data flow.

@Composable
fun MyScreen(viewModel: MyViewModel) {
   val displayItemState = viewModel.displayItemState

   LaunchedEffect(key1 = displayItemState) {
       snapshotFlow { displayItemState }
           .distinctUntilChanged()
           .filter { it.viewIntent != null }
           .collectLatest { displayItemState ->
               context.startActivity(displayItemState.viewIntent)
           }
   }
}
Enter fullscreen mode Exit fullscreen mode

snapshotFlow is called with a block of code that reads from displayItemState. This creates a Flow that emits a new value every time displayItemState changes.
The Flow returned by snapshotFlow is collected inside a LaunchedEffect. As we discussed earlier LaunchedEffect is a composable in Jetpack Compose that can be used to perform side-effects, such as collecting from a Flow, in response to state changes.
When displayItemState changes, snapshotFlow emits a new value. This value is then collected inside the LaunchedEffect, triggering any side-effects that depend on the latest value of displayItemState.

Conclusion

In conclusion, understanding and effectively managing side effects is a crucial aspect of developing robust and maintainable software. Side effects are inevitable in most applications, but with the right tools and strategies, we can ensure they don’t compromise the predictability and reliability of our code.
Remember, the key to effectively using these tools is understanding the lifecycle of your composables and how state changes propagate through your application. With this knowledge, you can write code that’s not only easier to understand and maintain but also more efficient and responsive.
So go forth, compose with confidence, and embrace the power of controlled side effects!😊

Top comments (0)