Search code examples
androidkotlinandroid-jetpack-compose

derivedStateOf not responding. Help me understand why


I have derivedStateOf that uses 3 different states: heightValue, isOk, and selectedOption which was deconstructed in (selectedOption, onOptionSelected) and possibly is the reason why. It will not respond to changes in selectedOption unless I specify it in the remember(selectedOption). I'm trying to understand why and if my fix was the correct fix or should I have handled the deconstruction differently.

val radioOptions = listOf("A", "B", "C", "D")
var heightValue by remember { mutableIntStateOf() }
var isOk by remember { mutableStateOf(true) }
val (selectedOption, onOptionSelected) = remember { mutableStateOf(radioOptions[0]) }

//val calculatedValue by remember() { /*This won't work. Next line is the fix*/
val calculatedValue by remember(selectedOption) {
    derivedStateOf {
        calcNewValueInch(
            pickerValue = heightValue,
            isOk = isOk,
            selectedOption = selectedOption,
        )
    }
}

Solution

  • derivedStateOf only works with State objects. selectedOption is not a State, threfore any changes to it won't be detected.

    Let's have a look at this in more detail. In your original, not working example with just remember(), derivedStateOf is executed on first composition. It also executes the lambda and keeps track of all State objects that were used. We expect that to be the following:

    1. heightValue
    2. isOk
    3. selectedOption

    Then the result of derivedStateOf is remembered so it won't be executed again on recomposition. That's ok: The now created derived state automatically updates when any of the States in the list above changes.

    Except it only works for 1. and 2., it doesn't work for 3.

    Since derivedStateOf only keeps track of actual State objects that were used in its lambda, it is quite obvious that it won't update when 3. changes: selectedOption is just a simple String, not a State. The more interesting question is why it does work for 1. and 2. though, since they are also no States, just an Int and a Boolean. But that's not entirely true. They are actually delegates that you retrieved by using the by keyword. What that means is that you can use them as simple Int and Boolean values, but whenever their value is read, the getValue function of the delegated object is actually executed. When their value is set, setValue will be called instead. Kotlin hides all that from you so your code looks cleaner, but that is what actually happens when a delegate is accessed. In conclusion, 1. and 2. are delegates to a State, and therefore derivedStateOf can see those States and monitor their changes.

    selectedOption, however was created by deconstructing the MutableState. That leaves you with a simple String containing the value at the time of deconstruction. The tricky thing is that for most cases this will be sufficient, so it doesn't feel that much different than the delegated States:

    Text(selectedOption) 
    

    This will work as expected and updates everytime the underlying State of selectedOption was changed. That's because the Compose runtime keeps track of all State reads, and deconstructing a State counts as such. When the State is changed, it recomposes the function, so the deconstruction is repeated and selectedOption is updated. But the derivedStateOf lies behind a remember and isn't affected by such recompositions. That's why it keeps track of all State objects itself. But it never saw the deconstruction of the State behind selectedOption, so it only sees the result, which is a simple String, not a State.

    We can now revise the above list of States that derivedStateOf keeps track of:

    1. The State object behind the heightValue delegate
    2. The State object behind the isOk delegate
    3. Not selectedOption, since that is just a simple String

    The only States that are accessed in the lambda are 1. and 2., so whatever you do with 3., it won't affect the derivedStateOf.

    When you use remember(selectedOption) it seems to work, but what actually happens is that the entire derived State is thrown away each time selectedOption changes and a new one is created. The lambda is executed again and the above list of State objects that should be observed for changes is created, but it doesn't matter that 3. is not actually on the list, because you just create a new derived State object when that changes, you don't let the current derived State object update.

    The proper solution to the problem is to keep the State behind selectedOption around. Either by also delegating it like you did with 1. and 2., or if for whatever reason you do not want to do that, by something like this:

    val selectedOptionState = remember { mutableStateOf(radioOptions[0]) }
    val (selectedOption, onOptionSelected) = selectedOptionState
    
    val calculatedValue by remember {
        derivedStateOf {
            calcNewValueInch(
                pickerValue = heightValue,
                isOk = isOk,
                selectedOption = selectedOptionState.value, // this accesses the State now
            )
        }
    }
    

    In that case I would advise you to remove the deconstruction entirely and only access selectedOptionState.value so the reader of your code isn't left to wonder why you sometimes use this and sometimes you use that to access the same State.