androidkotlinandroid-jetpack-composestatemutablestateof

Best practice in Compose to render lots of data using mutable states


My problem is, I have a lot of data and want to render it in Jetpack Compose in a table, being able to modify that data.

My situation

Let’s say I have a class called User, with the name and some nonsense value called foo:

class User (
    var firstName: String,
    var lastName: String,
    var foo: Int
)

Then, I have a list containing lost of users. For this example, let’s just assume I have a list of 100 guys named “John Cena” with a foo value of 0:

val users = List(100) {
    User("John", "Cena", 0)
}

Now, I want to render those users in a table. So every user gets a row like this:

@Composable
fun UserRow(user: User) = Row {
    Text(user.firstName)
    Text(user.lastName)
    Text(user.foo.toString())
}

And finally, we display a row like that for all the 100 users in our list:

@Composable
fun UserTable(users: List<User>) = LazyColumn {
    items(users) { user ->
        UserRow(user)
    }
}

And we call it like this in the Window:

UserTable(users)

Now I want to able to modify something from the frontend.

To do that, we give the foo text an on click action that will increment it’s value:

@Composable
fun UserRow(user: User) = Row {
    Text(user.firstName)
    Text(user.lastName)
    Text(
        text = user.foo.toString(),
        modifier = Modifier.clickable {
            user.foo += 1
        }
    )
}

However, that won’t display the new value as user.foo is not a MutableState. So here are some approaches on how to solve the issue, but they all don’t seem ideal to me.

Possible solutions

1. Make user.foo a mutable state

Change the user class definition to:

class User (
    var firstName: String,
    var lastName: String,
    foo: Int
) {
    var foo by mutableStateOf(foo)
}

Pros: No other changes needed, I can work with foo like with a normal Int value.

Cons: The User class is also part of the business logic and it kinda feels wrong to use MutableState there when it’s not related to compose. I think it’d be better to use normal Ints in the backend.

2. Manually trigger a recompose

Wrap the foo text in a key like this:

@Composable
fun UserRow(user: User) = Row {
    Text(user.firstName)
    Text(user.lastName)
    
    var updater by remember { mutableStateOf(false) }
    key(updater) {
        Text(
            text = user.foo.toString(),
            modifier = Modifier.clickable {
                user.foo += 1
                updater = !updater
            }
        )
    }
}

Now, every time updater changes, a recomposition is triggered.

Pros: Absolute control over when to recompose. Doesn’t affect the business logic.

Cons: Very ugly as you have to manually change updater, and also definitely not the intended way.

3. Use a separate variable to hold the displayed text

We can also hold the displayed value in a separate MutableState<String>:

@Composable
fun UserRow(user: User) = Row {
    Text(user.firstName)
    Text(user.lastName)
    
    var fooText by remember { mutableStateOf(user.foo.toString()) }
    Text(
        text = fooText,
        modifier = Modifier.clickable {
            user.foo += 1
            fooText = user.foo.toString()
        }
    )
}

Pros: Clear separation of UI and business logic

Cons: Still requires manual updating, although it’s not as ugly as the 2nd solution.

Summary

So to me the 3rd solution seems the best, but I somehow feel like this is not the cleanest / intended way to do it. What do you think about those solutions and is there maybe a better one?

Thank you and have a nice day

Edit

I get a lot of suggestions of making User a data class and using copy to change it. However, this would work for this example, but I think it wouldn’t for my actual use case.

In my actual use case, I have method that I’d like to call when changing foo. That method is more complex then just reassigning foo, it will maybe do some input validation, change other non-primitive attributes of the user, make calls to the database and maybe some other stuff:

class User (
    var firstName: String,
    var lastName: String,
    var foo: Int,
    var someOtherObject: SomeOtherClass

) {
    fun changeFoo(newFoo: Int) {
        if (foo > 9999) {
            someError()
        }
        foo = newFoo
        // make changes to other objects
        someOtherObject.doSomething()
        // some additional effects
        doSqlStuff(this)
        andWhatnot()
        // ..
    }
}

I want to be able to call this method from inside Compose, or somewhere inside the business logic without a relation to compose.

Btw, because someone asked: the user list is stored outside of compose. It is loaded from an SQLite database on startup, and whenever I change the user list, the same changes shall be made in the SQLite database. So just calling copy won’t update the database, instead I’d like to call the changeFoo method to keep it simple.

Also I’m curious if copying the object is as efficient as just reassigning one value.

I understand if it’s not possible to use the same method I use in my business logic, and expect Compose to still notice the changes. If that’s the case I will either use two different methods for Compose and not-Compose scopes, or use one of the manual updating approaches.

But if it is possible I’d like to know, because I’m still a beginner at Compose and I’d like to learn more about the way it works and the way you use it for big projects. (in my case it’s actually a Compose multiplatform project).

Sorry for posting a question similar to the old one, I just thought the old one was bad and didn’t address the actual problem I had (XY-problem stuff I guess)


Solution

  • You have not mentioned where exactly your List of User is located. Either way, you should make the properties of the User data class immutable to ensure that we update values in a way that Compose will detect and recompose.

    data class User (
        val firstName: String,
        val lastName: String,
        val foo: Int
    )
    

    If you are using a ViewModel, you would declare and update a Flow like this:

    private val _users = MutableStateFlow<List<User>>(emptyList())
    val users: StateFlow<List<User>> = _users
    
    fun updateFoo(modifiedIndex: Int, newFooValue: Int) {
        _users.update { userList ->  // update is thread-safe
            userList.mapIndexed { index, user ->
                if (index == modifiedIndex) {
                    user.copy(foo = newFooValue)
                } else {
                    user
                }
            }
        }
    }
    

    And then collect it in your Composable as

    val userList = viewModel.users.collectAsStateWithLifecycle()
    

    If you only want to have the users locally in your Composable, you can use mutableStateListOf:

    val userList = remember { mutableStateListOf<User>() }
    userList[updateIndex] = userList[updateIndex].copy(foo = 1)