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.
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)
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.
user.foo a mutable stateChange 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.
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.
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.
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
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)
This answer specifically addresses the edit to your question and is intended as an addendum to BenjyTec's answer that still applies in general.
You want to only have a Single Source of Truth for each datum used in your app. If the User object is stored in a database, then the SSoT is usually that database. Whenever you want to change anything you must only modify the SSoT - in this case the database.
When you architect your UI to only display what's in the database (the SSoT), then you'll have a clean way to change things while also always having a consistent state throughout your app. In a layered app you only want to pass state up from the data sources into the UI, while passing events (like the request to change a User) from the UI down to the data sources. This paradigm is called Unidirectional data flow (UDF).
This may sound complicated at first, but Kotlin provides a great tool to handle that easily: Flows.
You can set up your database to not only return the result of a query once, you should instead tell it to observe the database for changes and provide a Flow of results, with a new value whenever anything changes. You then just pass the Flow up through the layers into the UI where your composables convert the Flows into Compose State objects by calling collectAsStateWithLifecycle(). Any change to the database will then automatically update the UI, without any further intervention necessary on your part. When using Room in your KMP project as a wrappper around an SQLite database, then it is very simple: Just wrap the return value of your DAO function into a Flow, like this:
@Query("SELECT * from user")
fun users(): Flow<List<User>>
Room will then automatically generate the code to observe the database for changes and emits new results into that flow - you do not need to handle anything yourself.
For this to work as intended you still have to make User an immutable data class. And since you don't want the view model as your SSoT anymore, as BenjyTec suggested, don't use a MutableStateFlow there, instead, just convert the database flow into a StateFlow using stateIn().
When you want to change a User, simply use the copy function to create the desired object. Then save this new object to the database. This is the part where an event is passed down the layers. Then the database will emit a new result into the flow which will get passed up the layers where it is displayed in the UI. The Flow makes this quite performant, and - depending on the database implementation - doesn't even need a full write/read cycle to the disk to emit the new result.
As a closing note, since you seem concerned about the performance: Yes, simply assigning a new value to a var is more performant than creating a copy of the entire object. But in most cases this is negligable since this new copy only copies the references to its properties, it doesn't create a deep copy, duplicating all properties. So it actually only writes some bytes of metadata, which is very, very fast.
This disadvantage is usually clearly outweighed by what you gain by only using immutable types with a SSoT: A consistent state throughout your app that is guaranteed by design and cannot be subverted by programming errors anymore, effectively eliminating an entire class of errors. You also gain some degree of thread-safety.
You just need to get your head wrapped around these concepts, then it becomes quite natural to use.