Side Effects Summary in Jetpack Compose

Do you find side effects in Jetpack Compose confusing? I do. So, I document the summary of using side effects here for my future reference.

ยท

5 min read

Side Effects Summary in Jetpack Compose

A side effect in Compose is a change to the state of the app that happens outside the scope of a composable function.

To handle these side effects, you can use effect handlers. There are 2 types of effect handlers.

Suspended Effect HandlerNon-suspended Effect Handler
LaunchedEffect()DisposableEffect()
rememberCoroutineScope()SideEffect()

Suspended Effect Handler

A suspended effect handler allows you to perform side effects in compose using coroutines.

Here is the summary that covers different scenarios:

ScenariosLaunchedEffect()rememberCoroutineScope()
Launch effect/coroutine from within a composable?YesNo
Launch effect/coroutine outside a composable? E.g. from the callbackNoYes
When effect/coroutine is started?LaunchedEffect() enters compositionCoroutineScope.launch() is called
When effect/coroutine is canceled?LaunchedEffect() leaves composition, LaunchedEffect() is restarted (effect's key changed)CoroutineScope leaves composition. Note: When coroutine is restarted, the previous coroutine will NOT be canceled
When effect/coroutine is restarted?LaunchedEffect()'s key changed (while it is still in composition)CoroutineScope.launch() is called again

Non-suspended Effect Handler

For non-suspended/non-coroutine side effects, you can use these non-suspended effect handlers.

ScenariosDisposableEffect()SideEffect()
Launch effect from within a composable?YesYes
Launch effect outside a composable? E.g. from the callbackNoNo
When effect is started?DisposableEffect() enters composition, after the current composition completesSideEffect() enters composition, after the current composition completes
When effect is canceled?Effect can NOT be canceled, since the execution can NOT be suspended(non-suspended function)Effect can NOT be canceled - same as DisposableEffect()
When effect is restarted?DisposableEffect()'s key changed (while it is still in composition)SideEffect() enters recomposition - every recomposition triggers the SideEffect() to run
When onDispose() is called?DisposableEffect() leaves composition, DisposableEffect() is restarted (effect's key changed) Note: When the effect completes, it will NOT trigger onDispose()N/A

Various Side-effect States

These are the various compose state helpers for effect handlers above.

rememberUpdatedState

rememberUpdatedState makes sure the MutableState is always updated with the latest value instead of caching the initial composition value.

See notes (1), (2), (3), (4) and (5) below.

@Composable
fun RememberUpdatedStated(value: String) {
    val oldValue by remember { mutableStateOf(value) }
    val newValue by rememberUpdatedState(value)

    // (2) LaunchedEffect is skipped during the second recomposition
    //       when value is changed/updated
    LaunchedEffect(true) {
        // (1) let's assume value is updated with a new value within 1 second delay
        delay(1000)
        // Access value, oldvalue and newValue here

        // (3) value is the initial value when LaunchedEffect is first called
        // (4) oldValue is the initial value from first composition
        // (5) newValue is the new value from second recomposition        
    }
}

Only the newValue has the latest updated value from the second recomposition

If you look at the rememberUpdatedState implementation, it applies the new value to the MutableState whenever it is called or during recomposition.

@Composable  
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {  
  mutableStateOf(newValue)  
}.apply { value = newValue }

Technically,

val newValue by rememberUpdatedState(value)

is equivalent to

var newValue by remember { mutableStateOf(value) }  
newValue = value

produceState

produceState is the sugar syntax for LaunchedEffect().

LaunchedEffect() below can be written as

@Composable
fun DemoLaunchedEffect(){
    var text by remember { mutableStateOf("")}

    LaunchedEffect(true) {
        repeat(10) { count ->
            delay(1000)
            text = count.toString()
        }
    }
}

produceState() below:

@Composable
fun DemoProduceState(){
    val text by produceState(initialValue ="") {

        repeat(10) { count ->
            delay(1000)
            value = count.toString()
        }
    }
}

One small difference of produceState is it produces State<T> instead of MutableState<T>. Thus, you see the text above is declared with val instead of var.

One additional thing produceState can do is awaitDispose() function, which allows you to detect when the produceState leaves the composition. This is similar to onDispose() from DisposableEffect()

@Composable
fun DemoProduceStateAwaitDispose(){
    val text by produceState(initialValue ="") {
        val job = MainScope().launch {
            repeat(10) { count ->
                delay(1000)
                value = count.toString()
            }
        }

        awaitDispose {
            job.cancel()
        }
    }
}

However, to use awaitDispose() you MUST manually launch a coroutine with a CoroutineScope. The following example uses the MainScope().

Without the MainScope() or any CorutineScope, the awaitDispose() will not be called. For example, the following code won't work.

@Composable
fun DemoProduceStateAwaitDispose(){
    val text by produceState(initialValue ="") {

        repeat(10) { count ->
            delay(1000)
            value = count.toString()
        }

        // Won't work - awaitDispose() won't be called
        awaitDispose {
            job.cancel()
        }
    }
}

For some reason, this requirement is not documented. It takes me a while to figure out the awaitDispose() requires you to launch the coroutine manually to get it working correctly.

derivedStateOf

Not sure why this is categorized under side effects, but what derivedStateOf() does, is combining multiple states into a single State.

@Composable
fun DemoDerivedStateOf() {
    var value1 by remember { mutableStateOf(true) }
    var value2 by remember { mutableStateOf(false) }

    val derivedValue by remember(value1) {
        derivedStateOf {
            "value1: $value1 + value2: $value2"
        }
    }
}

When value1 or value2 is changed, derivedValue is updated. The remember(value1) is needed so that, during recomposition, derivedStateOf() is skipped.

If you remove the remember(value1) as the following, everything still works correctly.

@Composable
fun DemoDerivedStateOf() {
    var value1 by remember { mutableStateOf(true) }
    var value2 by remember { mutableStateOf(false) }

    val derivedValue by derivedStateOf {
        "value1: $value1 + value2: $value2"        
    }
}

However, during every recomposition, this line "value1: $value1 + value2: $value2" is executed. Thus, it simply wastes unnecessary resources here.

snapShotFlow

snapShotFlow converts State<T> to Flow<T>. Here is an example:

@Composable
fun DemoSnapShotFlow(){
    var textState =  remember { mutableStateOf("") }

    LaunchedEffect(textState) {
        // Convert State<T> to Flow<T>
        val flow = snapshotFlow { textState.value } 
        // Ensure flow doesn't emit the same value twise
        flow.distinctUntilChanged()
        // Collect the flow
        flow.collect { text ->
            Log.d("[SnapShotFlow]", "collecting flow value: $text")  
        }
    }
}

For more information about collecting flow, refer to the following article:

Conclusion

I just merely document these side effect handlers' usages and their behaviors. However, I haven't known how to use them effectively yet. Sometimes I do find it confusing which one to use. :)

Source Code

This is my experimental code of playing around with side effects in Jetpack Compose. So it might be a bit messy here.

GitHub Repository: Demo_ComposeSideEffects

Did you find this article valuable?

Support Vincent Tsen - AndroidDev Blog by becoming a sponsor. Any amount is appreciated!

ย