Why `myValue` is always `-1` in this case? :thinki...
# compose
t
Why
myValue
is always
-1
in this case? 🤔
p
What if you change 'val myValue = ...' to 'val myValue by rememberUpdateState(...)'?
s
If you replace your first two lines with these three lines
Copy code
var recompTrigger by remember { mutableIntStateOf(0) }
recompTrigger
var cache = remember<Int?> { null }
Then you will see the "correct" behavior
It all comes down to two things: • trying to read a value in composition which is not backed by mutable state • Recomposition scopes
Your
myValue
is not backed by mutable state, so when it changes compose will not be notified about that change in order to re-read the latest value, recompose, and render the right thing When you increment recompTrigger, only the recomposition scope inside the button lambda gets recomposed, so your
val myValue...
is simply not re-evaluated. This is described much better and in super great detail here https://blog.zachklipp.com/scoped-recomposition-in-jetpack-compose-what-happens-when-state-changes/
So the effect of
recompTrigger
incrementing only invalidates the
Copy code
{
  BasicText("Value is $myValue ($recompTrigger)")
}
part, the part above is not recomposed at all, teherefore
getSomeValue()
is not re-run
When you move the state read to the outer recomposition scope, then that is able to also observe the state change, it re-composes, and since you are in there doing the cardinal sin of doing side-effects in-composition, your getSomeValue() will run on that recomposition, it will update the
myValue
and then you have successfully accidentally managed to observe the change of a non-mutable state and you do in fact render it on the screen 😄
p
myValue is not evaluated cause it's first value was captured by lambda. It is not related to state. recompTrigger will trigger recomposition in this case
If replace myValue for example to arrayOfInt , so that lambda will capture reference for this array, and then change element, it will work
s
myValue is not evaluated cause it's first value was captured by lambda.
What lambda?
It is not related to state. recompTrigger will trigger recomposition in this case
Yeah, but only inside the button, that's what I am saying here
p
Lambda that is content of button
s
Btw replacing your code with
Copy code
@Composable
fun Sample() {
  var recompTrigger by remember { mutableIntStateOf(0) }
  var cache = remember<Int?> { null }
  val myValue = cache ?: getSomeValue().also { freshValue ->
    if (freshValue != -1) {
      cache = freshValue
    }
  }
  BasicText("Value is $myValue ($recompTrigger)", Modifier.clickable { recompTrigger++ })
}

var count = -1

fun getSomeValue(): Int {
  count++
  if (count == 0) return -1
  return count
}
Also makes this "work", the value does update. Again because you are putting everything in one recomposition scope so it all invalidates together
But this snippet does so many sins anyway, reading non-mutable state in composition, calling functions with side-effects in composition 😄 I'd be worried about all of these, fix em, and then everything would just work anyway
Btw, one way to "break" this again would be just this
Copy code
@Composable
fun Sample() {
  var recompTrigger by remember { mutableIntStateOf(0) }
  var cache = remember<Int?> { null }
  val myValue = cache ?: getSomeValue().also { freshValue ->
    if (freshValue != -1) {
      cache = freshValue
    }
  }

  ExtraScope {
    BasicText("Value is $myValue ($recompTrigger)", Modifier.clickable { recompTrigger++ })
  }
}

@Composable
fun ExtraScope(content: @Composable () -> Unit) {
  content()
}
Again, because you're introducing a new recompositon scope which is only around
ExtraScope
And changing this to this
Copy code
@Composable
fun Sample() {
  var recompTrigger by remember { mutableIntStateOf(0) }
  var cache = remember<Int?> { null }
  val myValue = cache ?: getSomeValue().also { freshValue ->
    if (freshValue != -1) {
      cache = freshValue
    }
  }

  ExtraScope {
    BasicText("Value is $myValue ($recompTrigger)", Modifier.clickable { recompTrigger++ })
  }
}

@Composable
inline fun ExtraScope(content: @Composable () -> Unit) {
  content()
}
so adding an
inline
in that function will once again make the value update properly. Again, because they are once again all in one recomposition scope, and reading
recompTrigger
will invalidate the entire content
p
In each recomposition myValue will be created again, while button can skip recomposition and keep pointer to content lambda the same (with old value captured).
So when lambda reevaluate when trigger changed it will points to old myValue
s
No that is not the case unfortunately. The button in particular is not skipping recomposition, since it is in fact reading a state which has just changed. But we can evaluate this assumption by adding another side effect inside to
getSomeValue()
and see if it triggers or not. Let's change that function to this:
Copy code
fun getSomeValue(): Int {
  count++
  Log.d("tag", "count:$count")
  if (count == 0) return -1
  return count
}
With that change, let's have this without the
inline
keyword
Copy code
@Composable
fun ExtraScope(content: @Composable () -> Unit) {
  content()
}
When I run it this way, every time I click on the button nothing gets printed on the console. So it's the other way around of what you are describing. It's not that the entire composable recomposes but the button content is skipped. it's that the rest of the function does not recompose at all, and only the button lambda is recomposing reading the new state.
And of course, changing this back to
Copy code
@Composable
inline fun ExtraScope(content: @Composable () -> Unit) {
  content()
}
every time I click on the button I see the log printed on every frame.
Take this
Copy code
@Preview
@Composable
fun Sample() {
  var recompTrigger by remember { mutableIntStateOf(0) }
  var cache = remember<Int?> { null }
  val myValue = cache ?: getSomeValue().also { freshValue ->
    if (freshValue != -1) {
      cache = freshValue
    }
  }

  ExtraScope {
    BasicText("Value is $myValue ($recompTrigger)", Modifier.clickable { recompTrigger++ })
  }
}

@Composable
inline fun ExtraScope(content: @Composable () -> Unit) {
  content()
}

var count = -1

fun getSomeValue(): Int {
  count++
  Log.d("tag", "count:$count")
  if (count == 0) return -1
  return count
}
And run it on a preview in your IDE to see it for yourself.
And for a fun experience, add
var someAnimatedValue = transition.animateFloat(0f, 1f, infiniteRepeatable(tween(100))).value
to your function and see the
myValue
skyrocket. This is a good reminder to not do side-effects in composition (so never do what
getSomeValue()
does here. Read https://developer.android.com/develop/ui/compose/mental-model#simple-example, on the section "This function is fast, idempotent, and free of side-effects."
p
I got it! recompTrigger is not reading in Sample function. So when it changed, Sample fun is not recomposed, thus myValue is not recalculated. Only button does.
s
Yup exactly. But if
Button
was inline then it would still be considered that
Sample
function was reading the state, since it'd all be one recomposition scope. Compose is tricky like that in these scenarios. But this really is a result of many mistakes being done in the first place. Side effects in composition. Mutating local vars that are not backed by mutable state. If you just always avoid side effects and make sure that all of your state is either just immutable or is backed by mutable state, all of this headache is fully avoided.
p
Is basically
Donut Skipping
right? I think bellow article is good: https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose I think Google documentation should have a dedicated section for these optimizations. Another one that gets me very often is positional memoization, typically when using remember without keys, and the remember block depends on a Composable function parameter.