Elye
Elye

Reputation: 60341

What's the different using SideEffect and not using it in JetpackCompose?

I try to understand SideEffect of Jetpack Compose.

Other than the official document, I find 3 other reference

I'm am still confused. My simple question as below

What's the difference if I do this with SideEffect

var i = 0
@Composable
fun MyComposable(){
    Button(onClick = {}){
        Text(text = "Click")
    }
    SideEffect { i++ }
}

and without SideEffect

var i = 0
@Composable
fun MyComposable(){
    Button(onClick = {}){
        Text(text = "Click")
    }
    i++
}

Code example from https://www.section.io/engineering-education/side-effects-and-effects-handling-in-jetpack-compose/

Is there a way the i++ is still triggered in one case but not the other? How can I create a way to experiment with that?

Upvotes: 8

Views: 1258

Answers (2)

Jimmy
Jimmy

Reputation: 81

To simply put, a SideEffect will make sure whatever is in the block will be triggered after all remember components' onRemembered() are called. This will make sure the block inside a SideEffect will get the most updated values.

Let's take a look at some examples. Here's a simple example that simply shows a Text :

@Composable
fun Counter(modifier: Modifier = Modifier) {
    var counter by remember { mutableIntStateOf(1) }
    Text(text = "Counter = $counter")
    Log.d("TAG", "BEGIN : Counter = $counter")
    counter++
    Log.d("TAG", "END : Counter = $counter")
}

If we run this code, we will get the following output :

BEGIN : counter = 1
END : counter = 2

However, this will not trigger recomposition. So you can tell that when you do modification like this, it just act like any function, they will be called sequentially.

Now, if we place counter++ into a SideEffect :

@Composable
fun Counter(modifier: Modifier = Modifier) {
    var counter by remember { mutableIntStateOf(1) }
    Text(text = "Counter = $counter")
    Log.d("TAG", "BEGIN : Counter = $counter")
    SideEffect { counter++ }
    Log.d("TAG", "END : Counter = $counter")
}

We will get a different result :

BEGIN : counter = 1
END : counter = 1

What's more important is that this will trigger recomposition. If we didn't add a limit to counter inside SideEffect, you will see the number keep on increasing until it reaches Int.MAX_VALUE and crash.

If we wrap the second Log into a SideEffect, then we would get the same result as the first try :

BEGIN : counter = 1
END : counter = 2

So you can see the different between calling in and outside of a SideEffect.


Now, let's dive into the source code to get a better understanding.

A SideEffect is similar to remember, however it will be stored as a Operation.SideEffect and not Operation.Remember :

// Operation.kt
object SideEffect : Operation(objects = 1) {
    inline val Effect get() = ObjectParameter<() -> Unit>(0)

    override fun objectParamName(parameter: ObjectParameter<*>) = when (parameter) {
        Effect -> "effect"
        else -> super.objectParamName(parameter)
    }
    override fun OperationArgContainer.execute(
        applier: Applier<*>,
        slots: SlotWriter,
        rememberManager: RememberManager
    ) {
        rememberManager.sideEffect(getObject(Effect))
    }
}

Upon calling SideEffect {}, a Operation.SideEffect will be stored inside a sideEffects: MutableListOf<() -> Unit>() in RememberEventDispatcher :

// Composition.kt
private class RememberEventDispatcher(
    private val abandoning: MutableSet<RememberObserver>
) : RememberManager {
    // ...
    override fun sideEffect(effect: () -> Unit) {
        sideEffects += effect
    }
    // ...
}

The SideEffect operator will be triggered in CompositionImpl.applyChangesInLocked(ChangeList) after dispatching all remember observers :

manager.dispatchRememberObservers()
manager.dispatchSideEffects()

If you were to do any modifications outside of SideEffect, it will happen before applyChangesInLocked is called.


Next, let's take a look at the differences between calling counter++ outside and inside a SideEffect.

If we tried to modify counter outside a SideEffect, we will find candidate.snapshotId == id is true in the following code, which will avoid snapshot.recordModified(state) being triggered. This will also avoid recomposition to occur due to the change in counter :

internal fun <T : StateRecord> T.overwritableRecord(
    state: StateObject,
    snapshot: Snapshot,
    candidate: T
): T {
    if (snapshot.readOnly) {
        // If the snapshot is read-only, use the snapshot recordModified to report it.
        snapshot.recordModified(state)
    }
    val id = snapshot.id

    if (candidate.snapshotId == id) return candidate

    val newData = sync { newOverwritableRecordLocked(state) }
    newData.snapshotId = id

    snapshot.recordModified(state)

    return newData
}

However, if we modify it inside a SideEffect, then candidate.snapshotId == id will return false. This will record the modification, which at the end will trigger recomposition to occur.

As for why candidate.snapshotId == id have different result, all I can say is that's how internal takes care of it.

Upvotes: 1

Elye
Elye

Reputation: 60341

The SideEffect function is a scope of code triggered outside of the Compose Function. I found a way to differentiate them.

If I run it as below

@Composable
fun TrySideEffect() {
    var timer by remember { mutableStateOf(0) }
    Box(contentAlignment = Alignment.Center) {
        Text("Time $timer")
    }

    Thread.sleep(1000)
    timer++
}

The above code will only show 0. The timer++ has no impact, as it was changed while it is being composed, given it's part of the composable function.

However, if we use SideEffect as shown below, given it is not part of the compose function, the timer++ will trigger this, and this will make the composable function recompose again and again (Given SideEffect is being called on each Composable). This will make the Text show 0, 1, 2, 3, 4...

@Composable
fun TrySideEffect() {
    var timer by remember { mutableStateOf(0) }
    Box(contentAlignment = Alignment.Center) {
        Text("Time $timer")
    }

    SideEffect {
        Thread.sleep(1000)
        timer++
    }
}

Additional Info

To make it a little interesting, if I put on the below code, then the text will display 0, 2, 4, 6 ... (given the first ++timer will happen without composing, and the ++timer that happen in the SideEffect will trigger it)

@Composable
fun TrySideEffect() {
    var timer by remember { mutableStateOf(0) }
    Box(contentAlignment = Alignment.Center) {
        Text("Time $timer")
    }

    SideEffect {
        Thread.sleep(1000)
        timer++
    }

    Thread.sleep(1000)
    timer++
}

Another interesting note, comparing SideEffect with LaunchEffect

If we use LaunchEffect, the number will only increment once, i.e. from 0 to 1. This is because unlike SideEffect, LaunchEffect only triggered on the first recomposition, and not change on the subsequent recomposition (unless we change the key1 value, so it will be triggered upon change of key1 value.).

@Composable
fun TrySideEffect() {
    var timer by remember { mutableStateOf(0) }
    Box(contentAlignment = Alignment.Center) {
        Text("Time $timer")
    }

    LaunchEffect(key1 = Unit) {
        delay(1000) // or Thread.sleep(1000)
        timer++
    }
}

Upvotes: 7

Related Questions