I have a `Text` that gets recomposed even though i...
# compose
r
I have a
Text
that gets recomposed even though its value is not based on mutable state. In particular, this happens when I include it in another composable that has a
Slider
with its own mutable state, and it doesn't happen when I put it in a separate composable (see thread for code). I know we're not supposed to depend on when recomposition happens or doesn't happen; I'm just trying to understand how Compose works (and avoid bugs that are hidden until code is refactored :) )
If I call
DiscreteSlider()
the
Text
updates; if I call
DiscreteSliderInParts()
it does not.
Copy code
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent(null) {
            Column {
                DiscreteSlider()  // Text gets recomposed
                //DiscreteSliderInParts() // Text does not get recomposed
            }
        }
    }
}

var sliderValueFloat by mutableStateOf(0f)
var sliderValueInt: Int = 0

@Composable
fun DiscreteSlider() {
    Slider(
        value = sliderValueFloat,
        onValueChange = {
            sliderValueInt = round(it).toInt()
            sliderValueFloat = it
        },
        valueRange = 0f..10f,
        steps = 9
    )
    Text(text = sliderValueInt.toString())
}

@Composable
fun DiscreteSliderInParts() {
    DiscreteSliderSlider()
    DiscreteSliderText()
}

@Composable
fun DiscreteSliderSlider() {
    Slider(
        value = sliderValueFloat,
        onValueChange = {
            sliderValueInt = round(it).toInt()
            sliderValueFloat = it
        },
        valueRange = 0f..10f,
        steps = 9
    )
}

@Composable
fun DiscreteSliderText() {
    Text(text = sliderValueInt.toString())
}
(I'm on beta02.)
z
Each composable function gets a recompose scope – the scope begins when the function’s body starts executing, and ends when it stops. When function reads a snapshot state, the recompose scope of the function that the read is executed in is the one that gets marked as dirty and will be recomposed for the next frame. When a recompose scope gets recomposed, it just gets its function called again – and the entire function body runs (although if it calls other composable functions, they may just return cached values). In this case, the.
DiscreteSlider
is reading
sliderValueFloat
. So when your
onValueChange
mutates this value, it marks
DiscreteSlider
as dirty, and the entire
DiscreteSlider
function will be called again for the next frame. The fact that you’re then passing the value of
sliderValueFloat
to
Slider
doesn’t affect `DiscreteSlider`’s recomposition.
r
Well I think it's "off by two" now because I'm more confused :). So basically, any value that changes (mutable state or not) will trigger recomposition if the composable that reads it happens to be in the same scope as another composable that is updated by mutable state?
z
Ugh, slack is just destroying my messages today
No, the fact that
sliderValueInt
changes is irrelevant. As long as at least one value that is read in a recompose scope changes, that entire recompose scope needs to be recomposed. So in this case that’s triggered by
sliderValueFloat
.
If you remove
sliderValueFloat = it
from your event handler, or pass a different (non-mutable) value of
sliderValueFloat
to
Slider(value =
, the recomposing should stop.
r
I meant that, in this case, the mutable state
sliderValueFloat
causes the
Text
to recompose, even though
sliderValueInt
changed but is not mutable. It still seems strange that we can get different behavior based upon how things are packaged. But so it is. Thanks for the explanation.
z
I mean, most code will execute differently depending on how it’s written 😛 Reading a state causes the entire composable function or lambda in which the state was read to recompose. Your
Text
could use a hard-coded value and the enclosing function would still execute when any states it reads changes. One thing that I found helpful to build my intuition around this is to think of every state read as being moved to the top of the function by the compiler.
sliderValueFloat
has to be read before the
Slider
function can be called, so the read is associated with the outer function, not the one it calls. I suppose the compose compiler could potentially try to be extra clever and realize that
sliderValueFloat
was only read in order to pass it to
Slider
, and thus only associate its read with `Slider`’s recompose scope. I don’t know the actual reasons for not doing this, but I’m guessing it would make the whole mechanism an order of magnitude more complex, and probably take more memory (if you performed some operation on the value before passing it to
Slider
, the call to
Slider
would effectively need to be wrapped in an anonymous function to perform the operation since the
Slider
bytecode can’t be modified for each caller).
It still seems strange that we can get different behavior based upon how things are packaged.
This is probably why the compose docs and team all stress so heavily that it is bad to rely on how many times any given function is actually recomposed at runtime.
r
I had always taken that message to mean things can be recomposed n times, so you should not have side effects. In this case, n is zero if the function is in the "wrong" scope.
There seems to be more to this. This code does not cause a recompose of the
Text
when it is not based on mutable state:
Copy code
var outputText = "123"
//var outputText by mutableStateOf( "123")
var buttonText by mutableStateOf("Toggle")

@Composable
fun RecomposeTest() {
    Column {
        Button(
            onClick = {
                buttonText = if (buttonText == "Toggle") "Toggled" else "Toggle"
                outputText += "${kotlin.random.Random.nextInt(48, 58).toChar()}"
            }
        ) {
            Text(text = buttonText)
        }
        Text(text = outputText)
    }
}
z
Yes, because the lambda
{ Text(text = buttonText) }
has its own recompose scope. The
buttonText
state is only read from that inner scope, it’s not read by
RecomposeTest
directly, so `RecomposeTest`’s recompose scope isn’t invalidated.
r
Thanks for explaining the role of scope. However, I'm going to assume that "piggybacking" on some other recomposition as a by-product of scope is not something we can rely on, and that anything we want updated in the UI should observable (mutable state). Now of course that was my assumption until this morning, when I changed the scope in my code and broke it due to the unintentional omission of
mutableStateOf
.
👍 1
z
I think that’s a pretty safe assumption 😉