SirBzik
SirBzik

Reputation: 21

Compose function recomposes when it passes value of the state to another function

I don't seem to find an explanation for this simple case. Something simple as a viewmodel holding counter flow:

class TestCounter : ViewModel() {
    private val _counter = MutableStateFlow(0)
    val counter = _counter.asStateFlow()

    init {

        viewModelScope.launch {

            while (true){
                delay(1000)
                _counter.update {
                    it + 1
                }
            }
        }
    }
}

And a composable function, that will recompose itself every time it passes value of the state it collects to another function. If I pass the state object itself to another function, there are no recompositions. Though I read its not the best practice.

@Composable
fun App() {

    val testCounter = TestCounter()

    val counter = testCounter.counter.collectAsState()

    SideEffect {
        println("RECOMPOSING EVERY TIME IF PASSING VALUE")
    }

    DrawCount(counter.value)
//  DrawCount(counter)

}

I miss something here. If it is a normal behaviour, then how would I use delegates (by) or a data class for UI state holding a bunch of values for different functions. It always results in unnecessary recompositions. Does not matter if the value is stable or not.

Upvotes: 1

Views: 672

Answers (1)

tyg
tyg

Reputation: 15803

The rule is simple: Every composable that reads the value of a State object is executed again when that value changes.

Your App composable reads the value of the counter State so that it can pass it to DrawCount. When counter changes every second, then App needs to be recomposed every second. That's all perfectly fine. It doesn't matter if you delegate the State, that would just do the same under the hood.

It isn't unnecessary, though, because on every recomposition DrawCount is executed again with the new value. And that's important, otherwise that composable wouldn't be able to update.

You can wrap code in remember statements to skip it during recompositions. That's what is done internally by collectAsState, so the flow isn't actually collected a second time.

That isn't feasable here because you want to execute DrawCount, so you need to pass it the State value. Another solution is to defer reading the Sate to where it is actually needed: In the DrawCount function itself. That can be done by wrapping counter in a lambda:

DrawCount({ counter })

Since a lambda is an (anonymous) function this code only declares the function, it isn't executed (yet). That also means that the counter State is not read here. That is an important distinction because now App does not read the counter State at all so it will not be recomposed when that changes.

For this to work you have to adapt DrawCount so it now takes a function as its parameter:

fun DrawCount(counter: () -> Int) {
    Text(counter().toString()) // ... or whatever you want to do with counter
}

Note that counter is now invoked as a function (counter() instead of counter). Only here the State is actually read and only DrawCount is recomposed when that changes.

I just answerd another question where I explained in more detail the pros and cons of wrapping a value in a lambda.


The only thing in your example code that is actually of concern is how you create your view model. By using TestCounter() you create a new instance on every recomposition (in this case, every second). The counter is started again (beginning from 0) and the old counter is thrown away together with the old view model.

What you want is to always get the same view model instance. Wrapping it in remember isn't enough because the view model needs to outlive the composable. Everything that is remembered only survives recompositions, it will not survive when the composable leaves the composition. The view model however should still be the same so that when the composable enters the composition again you will get the same instance you got last time.

This is why the Compose framework supplies you with a special viewModel() function that does all that for you.

If you need an instance of TestCounter the proper way to do it is:

val testCounter: TestCounter = viewModel()

Upvotes: 2

Related Questions