How to prevent unnecessary recompositions in Jetpack Compose
Asked Answered
V

3

9

I have a TextField for a search query and a Button that will execute the search and the results are shown in a column. Since the search takes a few seconds to run I want it to be executed on button press and not on text change.

Here is a simplified demonstration:

Column {
    val list = remember { mutableStateListOf<String>() }
    val textFieldValue = remember { mutableStateOf(TextFieldValue("")) }

    TextField(
        value = textFieldValue.value,
        onValueChange = { textFieldValue.value = it }
    )

    Button({
        list.clear()
        list.addAll(textFieldValue.value.text.split(""))
    }) {
        Text("Search")
    }

    list.forEach {
        println("test")
        Text(it)
    }
}

After the first time that the button is pressed, the foreach loop will run on text change. Even clicking on the TextField will rerun the loop. This doesn't run the search on text change, but re-renders the results and that causes glitches while typing in the text field.

How can this be prevented?

Viscount answered 3/8, 2021 at 20:13 Comment(0)
N
16

The above is true for Jetpack Compose. The author wanted to know about Compose Desktop, and it's not the same there yet, because it's in alpha and is not optimised that much yet.

Modifying a mutableState value always leads to a recomposition of all views, which its read value.

Any changes to value will schedule recomposition of any composable functions that read value. documentation

The way to stop it is moving out all views that reads mutableState value into a separate view. It'll be recomposed for each mutableState value change, but it won't affect the container.

In your case it's pretty simple: just move TextField and pass textFieldValue into the new function. You can forward all params you need, like modifier, textStyle, etc.

@Composable
fun TestView(
) {
    Column {
        val textFieldValue = remember { mutableStateOf(TextFieldValue("")) }
        val list = remember { mutableStateListOf<String>("test") }

        TextField(textFieldValue)

        Button({
            list.clear()
            list.addAll(textFieldValue.value.text.split(""))
        }) {
            Text("Search")
        }

        list.forEach {
            println("test $it")
            Text(it)
        }
    }
}

@Composable
fun TextField(
    textFieldValue: MutableState<TextFieldValue>,
) {
    TextField(
        value = textFieldValue.value,
        onValueChange = { textFieldValue.value = it }
    )
}

I'm not sure why there's no system function with this semantics, but in compose they prefer State hoisting pattern to match UDF.

Nic answered 4/8, 2021 at 2:44 Comment(9)
Your code is correct, but you are mistaken about why. Modifying a value inside a remember does not inherently cause anything to happen, and the location of the remember call is not important. You could, if you liked, set up textFieldValue and pass it to TextAndButton, and it would still work properly.Langlauf
@RyanM I'm pretty confused right now =) even moving Button seems not necessary. I've updated the code in the answer that doesn't leads to a recomposition too. Looks like it's smart enough to not recompose the whole view if only one view depends on it. but how is it different from the original code? only TextField was depending on its value there. And if this is so, why all TextFields accepts value+onValueChange instead of MutableState? This would prevent recompositions in many cases.Nic
@RyanM I kind of sorted that out and updated my answer, still not sure why there's no defined TextField with MutableState parameter in Compose framework, as most of users would use it without a model, just with remember, so having both variants would be cool.Nic
It seems that compose is smart enough to see the link, even if you put the variable in another composable. I believe there's no way around it because this is how the designers wanted it to be. The only thing you can do is to make your view as simple as possible and precalculate everything so that each recomposition is as fast as possible.Viscount
@Viscount doesn't my answer solves your question? It stops recompositions of the whole view and only recomposes the TextFieldNic
I tested it and the foreach loop is run on text change and even on click.Viscount
@Viscount quite strange, it works in my case. Are you using stable compose version 1.0.0? Can you try code from my original answer stackoverflow.com/revisions/68644780/1? it should work 100%Nic
It's the same with TextAndButton. I'm using compose for desktop, maybe it's behavior is different from mobile.Viscount
@Viscount oh I haven't noticed the desktop tag. Yes, it's pretty raw so recomposition may not be ideal yet. android compose hasn't been focused much on performance until betas. Most probably it'll be improved for desktop in future releases, but you can clarify this in kotlin slack, #compose-desktop channel. I think until it's not much close to release that's the best place to ask questions, as there's not many experts at all and in slack you can communicate with the maintainersNic
B
0

I don't prefer moving MutableState<> around as parameter just like i never use LiveData<> as parameter. Instead you could turn reading into lambda:

@Composable
fun TextField(value: ()->TextFieldValue, 
              onValueChange: (TextFieldValue)->Unit) {
    TextField(
        value = value(),
        onValueChange = { onValueChange(it) }
    )
} 
// call like
TextField(
        value = { textFieldValue.value },
        onValueChange = { textFieldValue.value = it }
)
Billi answered 12/9, 2021 at 14:12 Comment(3)
I don't understand what this achievesViscount
@Viscount same as accepted answer, just different approach on extracting TextField into new Composable functionBilli
To convert a MutableState into a () -> T and (T) -> Unit, you can use remember {{ mutableState.component1() }} and remember { mutableState.component2() }.Antione
I
-2
Column {
    val list = remember { mutableStateListOf<String>() }
    var textFieldValue = remember { mutableStateOf(TextFieldValue("")) }
    var searchTerm = remember { textFieldValue.value.text.copy() }

    TextField(
        value = textFieldValue.value,
        onValueChange = { textFieldValue.value = it }
    )

    Button({
        searchTerm = textFieldValue.value.text.copy()
        list.clear()
        list.addAll(searchTerm.text.split(""))
    }) {
        Text("Search")
    }

    list.forEach {
        println("test")
        Text(it)
    }
}

Try this

Indigene answered 3/8, 2021 at 20:49 Comment(4)
This has multiple compile errors. textFieldValue is a MutableState<TextFieldValue> and doesn't have a copy method. searchTerm is val and cannot be assigned to.Viscount
Still has compile errors. textFieldValue.value.text is a String and doesn't have a copy method. You don't actually need to copy a string in Kotlin, just assigning it to a new variable creates a new instance.Viscount
Alright if you know about it, then just please make the necessary modifications and check the result. ThanksIndigene
I don't see how this attempts to avoid any recompositions at all.Prison

© 2022 - 2024 — McMap. All rights reserved.