android-jetpack-composeside-effects

Updating local variables in jetpack compose and side effects


Reading about side effects in jetpack compose (thinking in compose), I find this example:

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

As described in context of this example, this is incorrect, since it is not allowed to modify the local variable items. Now I wonder, what is allowed and what not. How does compose know, that it can evaluate the item in myList in parallel? Would it be allowed, if var items was moved into the Column-scope?

Even further, is updating variables allowed at all? Could we do something like:

class LongLastingClass() {
   var someState = mutableStateOf(0) 
      private set
    
   fun update(value: Int) {
      someState = value
   } 
}

@Composable
fun ListWithUpdate(value: Int) {
   val longLastingObject = remember{ LongLastingClass() }
   longLastingObject.update(value) 
   ...
}

Or would the update have to be moved to something like a side effect? Or is this construct a problem, since we better should not modify state-variables within a composable, so a side effect would be needed?


Solution

  • Ignoring Row and Column for now, as they are inline functions and which are special, you should never write code in Compose that is dependent on when a composable lambda will execute. It may not produce the result you were expecting.

    If you changed the above to,

    @Composable
    @Deprecated("Example with bug")
    fun ListWithBug(myList: List<String>) {
        var items = 0
        A {
            B {
              for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
              }
            }
            Text("Count: $items")
        }
    }
    

    This code is only valid if every line calling ListWithBug always calls A invoking its lambda which always calls B invoking its lambda. If A's lambda is invoked and B is skipped then the value of items will be 0 when Text("Count: $items") executes. In other words, this code only works if B doesn't skip and the lambda passed to B doesn't skip, whenever ListWithBug is executed.

    Parallelism is not required for this code to be wrong, just skipping.

    This way around this not use Compose to count. If you changed this to,

    @Composable
    fun WorkingList(myList: List<String>) {
        A {
            B {
              for (item in myList) {
                Text("Item: $item")
              }
            }
            Text("Count: ${myList.size}")
        }
    }
    

    it produces the same result without relying when or if a composable lambda will executed.