Leveraging the Snapshot Mutation Policies of Jetpack Compose

Leveraging the Snapshot Mutation Policies of Jetpack Compose

Hey Composers 👋🏻, The heart💚 of Jetpack Compose is a State that tells compose when to recompose UI. In the state management with compose, we can specify policies by which we can tell compose when exactly to recompose and it's a Snapshot Mutation Policy in compose. Let's see it in detail.

🕵🏻Preface

While developing apps with Jetpack Compose, we often have used the function mutableStateOf as follows:

fun Something() {
    var value by remember { mutableStateOf("Initial Value") }
}

Let's take a look at the signature of the function mutableStateOf.

fun <T : Any?> mutableStateOf(
    value: T,
    policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T>

Did you observe that second parameter? 🤔. Yes, of type SnapshotMutationPolicy. We are going to explore that only.


🤷🏻‍♂️What is SnapshotMutationPolicy?

According to the docs:

SnapshotMutationPolicy is a policy to control how the result of mutableStateOf report and merge changes to the state object.

In short, with this policy, we can tell compose when to consider a state as changed which then results in the recomposition. This policy can be passed as a parameter to mutableStateOf and compositionLocalOf.

A custom mutation policy can be created by implementing this interface. Here's the interface definition 👇🏻

interface SnapshotMutationPolicy<T> {
    /**
      Determine if setting a state value's are equivalent and should be 
      treated as equal. If [equivalent] returns `true` the new value is not 
      considered a change.
     */
    fun equivalent(a: T, b: T): Boolean

    /**
       Merge conflicting changes in snapshots. This is only called if
       [current] and [applied] are not [equivalent]. If a valid merged value
       can be calculated then it should be returned.
     */
    fun merge(previous: T, current: T, applied: T): T? = null
}

Also, did you observe that by default structuralEqualityPolicy() is specified as a policy whenever we use mutableStateOf(). What is it? Let's see.

📦Pre-defined policies in Standard Compose library

Compose standard library comes with pre-defined snapshot mutation policies:

1. structuralEqualityPolicy

This policy treats values of a State as equivalent if they are structurally (==) equal.

Setting MutableState.value to its current structurally (==) equal value is not considered a change. That is a reason why Google recommends using data class whenever a state model is created because data class implements method equals() under the hood which then ultimately helps this mutation policy to work well.

This is how StructuralEqualityPolicy is implemented:

private object StructuralEqualityPolicy : SnapshotMutationPolicy<Any?> {
    override fun equivalent(a: Any?, b: Any?) = a == b
}

2. referentialEqualityPolicy

This policy treats the values of a State as equivalent, if they are referentially (===) equal.

Setting MutableState.value to its current referentially (===) equal value is not considered a change. So whenever we need to handle state recomposition in compose based on referential equality then use this policy.

This is how ReferentialEqualityPolicy is implemented:

private object ReferentialEqualityPolicy : SnapshotMutationPolicy<Any?> {
    override fun equivalent(a: Any?, b: Any?) = a === b
}

3. neverEqualPolicy

This policy never treats the values of a State as equivalent. If this policy is used, then every time MutableState.value is updated, it's considered as a change.

This is how NeverEqualPolicy is implemented:

private object NeverEqualPolicy : SnapshotMutationPolicy<Any?> {
    override fun equivalent(a: Any?, b: Any?) = false
}

🪄Example

Let's understand it with a very basic example.

This example is not based on something real practical use case but it's just for understanding.

Have a look at the composable function 👇🏻.

data class MyState(val state: String, val fieldToSkip: String)

@Composable
fun MyDemo() {
    var myState by remember {
        mutableStateOf(
            value = MyState(
                state = "Initial value",
                fieldToSkip = "Some value"
            )
        )
    }
    println("MyState = $myState")

    LaunchedEffect(Unit) {
        myState = myState.copy(state = "New Value")
        delay(100)
        myState = myState.copy(fieldToSkip = "Value 2")
    }
}

In this, we have a state model: MyState which has two state properties i.e. state and fieldToSkip. In composition, whenever the fields are updated, the recomposition will occur.

▶️ So if we run the above code, the output will be like this:

 MyState = MyState(state=Initial value, fieldToSkip=Some value)
 MyState = MyState(state=New Value, fieldToSkip=Some value)
 MyState = MyState(state=New Value, fieldToSkip=Value 2)

If we want to avoid recomposition whenever fieldToSkip is changed then we'll have to define our own policy for this use case. So let's create it.

object MyStatePolicy : SnapshotMutationPolicy<MyState> {
    override fun equivalent(a: MyState, b: MyState): Boolean {
        return a.state == b.state
    }
}

As you can see, in the policy, we are only calculating equality based on the state parameter and not considering fieldToSkip in it. So let's change the compose's implementation to respect our policy.

 var myState by remember {
     mutableStateOf(
         value = MyState(
             state = "Initial value",
             fieldToSkip = "Some value"
+         ),
+        policy = MyStatePolicy
     )
 }

After this change, if we ▶️ run the same code again, the output would be different:

MyState = MyState(state=Initial value, fieldToSkip=Some value)
MyState = MyState(state=New Value, fieldToSkip=Some value)

Now you can see that whenever fieldToSkip is updated, recomposition is not occurring and thus we controlled unnecessary recompositions for our use case.


Thus, SnapshotMutationPolicy can be helpful if used properly for the need of use cases and solving the conflicts which can occur while mutating values from snapshots. This can be useful to avoid unnecessary recompositions caused due to unused field's value updates and can help improve the performance a bit.

Hope you find this blog helpful 😀.

"Sharing is Caring"

Thank you! 😄


📚References

Did you find this article valuable?

Support Shreyas Patil by becoming a sponsor. Any amount is appreciated!