Effect Handlers in Jetpack Compose: A Complete Guide

Effect Handlers in Jetpack Compose: A Complete Guide

Effect Handlers: as the name suggests, they are used to handle the “side”-effects in Jetpack-Compose. But, what exactly is a *side-*effect?

According to docs: A side-effect is a change to the state of the app that happens outside the scope of a composable function.

This explains it all, but if you still didn’t get it, let’s take an Example…

class MainActivity : ComponentActivity() {

    var i=0 //Outside of compose

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme {
                Button(onClick = {}){
                    i++//SIDE EFFECT
                    Text(i.toString())
                }
            }
        }
    }
}

Here, our variable will increment every time the re-composition occurs, and it will affect the state of our app, but from outside of our COMPOSITION.

Suppose, in any other complex scenario, we perform some heavy operation, we should be able to handle the action/event/operation efficiently and independently of the Compose Lifecycle.

Effect-Handlers are on then rescue ;)

1. LaunchedEffect:

A compose function that allows you to pass 1 or more keys and a code that you want to execute every time our key changes.

fun LaunchedEffect(
    vararg keys: Any?,
    block: suspend CoroutineScope.() -> Unit
)

Key: Generally a composed state that acts as a trigger for executing the block

Block: Provides a coroutine scope to run a (suspend)block of code based on a key change.

Example:

var text = remember{
    mutableStateOf("")
}
LaunchedEffect(key1 = text){//A compose state key
    delay(100)//Supports suspend function
    print("Printing $text")//Everytime text changes
}

Uses-Cases:

  1. For showing Snackbar

  2. For re-setting UiStates

  3. For Navigation Events

  4. 4. Starting Animations/Some one-time events

2. rememberCoroutineScope:

A composable function that gives reference to a composition-aware coroutine scope.

Example:

@Composable
fun SomeCompose() {
    val scope = rememberCoroutineScope()
    Button(
        onClick = {
            scope.launch {
                delay(300)
            }
        }
    ) {
        Text(text = "")
    }
}

As soon as the compose function leaves the composition, all coroutines in the scope will also get canceled.

This is a side-effect because it is only used for events like in the onClick which is targetted to some outside state change.

Uses-cases:

  1. onClicks

  2. Animations

  3. Callbacks

Prevent using it as much as possible. (Personal preferences)

3. rememberUpdatedState:

remember a mutableStateOf and update its value to newValue on each recomposition of the rememberUpdatedState call.

Using an example will give more clarity:

@Composable
fun SomeCompose(
    onEvent:()->Unit
) {
    LaunchedEffect(Unit){
        delay(3000)
        onEvent()
    }
}
  1. SomeCompose() is called with the initial onEvent value.

  2. We entered the LaunchedEffect and waited for delay to complete.

  3. Before the delay is finished we have a new onEvent and we pass it in the SomeCompose()

  4. It should recompose but due to LaunchedEffect, it's not possible.

  5. So, here only the older value of onEvent will be executed after the delay is completed.

@Composable
fun SomeCompose(
    onEvent:()->Unit
) {
    val onUpdatedEvent by rememberUpdatedState(newValue = onEvent)
    LaunchedEffect(Unit){
        delay(3000)
        onUpdatedEvent()
    }
}

We can use the rememberUpdatedState side-effect to store the latest state of any value, and only the updated value will be passed on to further uses.

4. DisposableEffect:

A compose function that allows you to pass keys and a block of code(similar to the LaunchedEffect) but with extra functionality to write a cleanup block of code.

Difference from LaunchedEffect: Here, the working of the Disposable effect is exactly similar to LaunchedEffect, it will also relaunch every time the key change. Differences are:

  1. No coroutine scope is present in DisposableEffect.

  2. Provides an inline function onDispose which will be executed every time the Effect is Destroyed depending on your keys.

  3. Generally used for registering and unregistering the callbacks.

Example:

val context = LocalContext.current
DisposableEffect(context){
    register()//Registering a callback is a side-effect and should be done one time
    onDispose {
        unregister()//Any Callback needs to be unregistered to prevent memory leaks
    }
}

5. SideEffect:

Reminding you of the working of compose, it will ONLY update the code/composable which are getting changes/affected with the compose state objects(eg. mutableStateOf). If any non-compose values are changed, then recomposition will not occur.

So, SideEffect is used to share the compose state with objects not managed by compose.

Let’s dive deeper into the explanation with an example:

var notComposeState = ""//Outside compose
Column {
    var composeState by remember {
        mutableStateOf("")
    }
    val notComposeUpdatedValue = someCompose(composeState,notComposeState)

    Text(text = "ComposeStateValue = $composeState")
    Button(onClick = { composeState+="*" }) {
        Text(text = "Update ComposeState")
    }

    Text(text = "NotComposeState = $notComposeUpdatedValue")
    Button(onClick = { notComposeState+="*" }) {
        Text(text = "Update NotComposeState")
    }
}
  1. There are 2 values here, 1 is a composed state and the other is not a state and is just a normal value coming from outside

  2. There are two simple buttons one to update the compose state value and the other to change the not Compose State value.

  3. You can easily tell that, when the Compose state is changed with a button click it will be shown in the UI as recomposition occurs to the Text composable we are using.

  4. But, if we directly use the notComposeState in the Text then it will not get updated as no recomposition will occur.

  5. So, we decided to use a separate function that will recompose every time only when the first value is changed, i.e. compose state variable is changed.

  6. In that function, we are using a SideEffect to get the latest notState value and then return it.

  7. Then in the UI, we show the returned latest value of notComposeState. Run the above code for more clarity.

  8. Whenever we click on the Update NotState button, the value in the notComposeState variable will be updated but as it is not a composed state the updated value will be not shown in the UI.

  9. So, when we click on the UpdateState button, composeState is updated and shown, but at the same time, the recomposition for the someCompose function happens and we get the latest NotState value to be shown in the UI.

Use-Case: To update any values from non-compose states in case of recompositions(from any other compose state).

On the other hand, it is incorrect to perform an effect before a successful recomposition is guaranteed, which is the case when writing the effect directly in a composable.

6. produceState:

It is a compose function that we can use to convert a non-compose state into a composed state.

I have not personally used this anywhere as it is just similar to generating a flow and then using collectAsState(). Also, this is built on top of other handlers and we can create our own handlers like this.

Example:

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

This is a generic function that we can use to pass the URL and load images and return the result as a state. Creating a flow here can be an alternative but this is more of a compose way of doing that.

7. derivedStateOf:

This compose function is used to optimize the performance by helping in reducing the recompositions. We can use it to convert one or multiple state objects into another state.

In simpler words, one value depends on another state, but the other state changes multiple times resulting in unnecessary recompositions. We can use derivedStateOf to create a cache and avoid recompositions when not required, given conditions.

If you check the internal implementation, its working is very different, you’ll find that it will only respond to the changes(given conditions) and update all the observers while maintaining the cache. If we are not using it then every time the calculations and recompositions occur.

Example:

var counter by remember {
    mutableIntStateOf(0)
}
val string by remember {
    derivedStateOf {
        if(counter>10){
            "The high counter values is $counter"
        }else{
            "Low counter"
        }
    }
}
Text(text = string)
Button(onClick = { counter++ }) {
    Text(text = "Add")
}

The string value is cached till 10 and after that, the counter is changed and only that integer value is getting updated in all observers.

If we don't use this, after every change recomposition occurs.

it acts similarly to the Kotlin Flows distinctUntilChanged() operator.

8. snapshotFlow:

It works exact opposite of collectAsState.

This compose function is used to convert a compose state into a flow that emits values whenever a compose state changes.

Pretty simple, right? Yes, it is!

Example:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

It is generally used to benefit from the power of flow operators.

Resources:

https://developer.android.com/jetpack/compose/side-effects

youtu.be/gxWcfz3V2QE

and My superpowers ;]

I hope you find this knowledge of SideEffect handlers helpful in your code. If yes, please make sure to clap clap clap and hit the follow button, for more such helpful content.