Hey everyone, I wanted to get your thoughts on an ...
# compose
d
Hey everyone, I wanted to get your thoughts on an architectural approach. I'm planning to create a screen in Jetpack Compose that contains multiple Compose components, where each component is responsible for fetching its own data. This would mean that each component would need its own ViewModel, etc. What do you think? Pros and cons?
p
Instead of each component having its own ViewModel, I would say each component would have each own "StateController" or "StateHoister". Basically is a class that acts as a mini ViewModel but is not tied to platform lifecycle and such. The scope of this StateController will be controlled by the screen ViewModel or perhaps by another parent StateController.
d
And is it ok that that state will bring the data to itself? Fetch data from remote?
đź’Ż 1
p
I have been using that approach and has been really handy. The problem it targets is the "big screen state". Before that, I use to design screens based on a huge state hosted in the ViewModel. But then any interaction with the component had to go through the ViewModel. This technique allows better component encapsulation
d
And the state holder is coupled to the screen lifecycle?
p
To me is more than ok. The only downside (perhaps good depending on the point of view) Is that the state controllers will have a lot of callbacks or interactions with the ViewModel that hosts it. A bit verbose compared to write the code directly in the ViewModel
Yes the State Controller receives lifecycle events the same as a ViewModel. Is basically a ViewModel without that name
d
Is there a downside to this going against the Android architecture? Which says that the screen has one defined state and now each card can have its own state?
p
Sort of:
Copy code
class ComponentStateController {
  uiState: StateFlow<ComponentUiState>
  fun start() {}
  fun stop() {}
}

sealed class 
ComponentUiState { ... }
d
And maybe it’s need a scope to fetch data from network
p
start() will be called from the ViewModel but you could also call it from Composable. Just be careful with not calling it during every recomposition and such
d
The state should be on remember no?
p
Preferably host it in the ViewModel or in a parent StateController. But nothing stops you to remember it in Composable scope
c
I tend to think of screens as nothing unique in themself. Especially when you deal with webpages or desktop apps which have considerably more content on a single screen, it can be helpful to think instead of the logical components of the UI. For mobile viewports, the screen is usually just a single logical component, which is why the recommendation is usually to have 1 ViewModel per screen, supplying everything to that whole screen. But dialogs, bottom sheets, side drawers, and other similar components are often technically part of the Screen, but are so large and logically distinct that it can be helpful to consider them as separate mini-screens with their own ViewModels, not directly tied to the screen’s VM. And dashboard-type screens which contain a list of separate “modules” can also often be designed easier and made more maintainable by having the parent Screen’s VM load which modules to display, but let each module treat itself as a mini-screen, loading all of its own content. I wouldn’t do this often, preferring to keep everything in a single VM per screen for as long as possible (less boilerplate, usually easier to understand), but for certain situations it can be a really handy tool. As for scenarios where you have separate logical components that you’d like to share some state, I prefer to fall back to a reactive Data layer and push the shared data out of the Compose UI world, where both VMs passively observe the data layer. This avoids coupling the two VMs to one another. The alternative would be having one VM collect the state of the other, for example, which I’ve found to be more brittle and confusing. Similarly, I also don’t usually tie my VMs to the Android Lifecycle, but instead simply to the Compose
rememberCoroutineScope()
and consider them ephemeral. Any data I would like persisted across lifecycle changes, I push back into the Data layer, so VMs are only active when their component is visible on the screen and can be created/destroyed as needed without risk of data loss.
👍 1
d
Thanks for your answer! Are there any restrictions to have multiple vm couples to same lifecycle?
I wonder if its ok to create a vm for each composable by specific key
p
Good points Casey. I agree đź’Ż
c
This is effectively the pattern I follow for my Compose UI code. I don’t use Android ViewModels since I’m not concerned with Android lifecycles, and use the Compose UI lifecycle instead. In practice, I use my Ballast library because I prefer the MVI model, but the library essentially just does this. And I like using Clean Architecture for managing access to the Data Layer, where I hold all of my persistent state.
Copy code
fun Navigation() {
    NavHost(...) {
        composable<AppScreen.Screen1> { Screen1() }
        composable<AppScreen.Screen2> { Screen2() }
    }
}

class Screen1ViewModel(val viewModelScope: CoroutineScope) {
}
@Composable
fun Screen1() {
    val coroutineScope = rememberCoroutineScope()
    val vm = remember(coroutineScope) { Screen1ViewModel(coroutineScope) }
}


class Screen2ViewModel(val viewModelScope: CoroutineScope) {
}
@Composable
fun Screen2() {
    val coroutineScope = rememberCoroutineScope()
    val vm = remember(coroutineScope) { Screen2ViewModel(coroutineScope) }
}
Then if you wanted sub-components, for example, it looks exactly the same.
Copy code
class Screen1ViewModel(val viewModelScope: CoroutineScope) {
    val component1Visible by mutableStateOf(false)
    val component2Visible by mutableStateOf(false)
}
@Composable
fun Screen1() {
    val coroutineScope = rememberCoroutineScope()
    val vm = remember(coroutineScope) { Screen1ViewModel(coroutineScope) }
    
    if(vm.component1Visible) { Component1() }
    if(vm.component2Visible) { Component2() }
}

class Component1ViewModel(val viewModelScope: CoroutineScope) {}
@Composable
fun Component1() {
    val coroutineScope = rememberCoroutineScope()
    val vm = remember(coroutineScope) { Component1ViewModel(coroutineScope) }
}

class Component2ViewModel(val viewModelScope: CoroutineScope) {}
@Composable
fun Screen2() {
    val coroutineScope = rememberCoroutineScope()
    val vm = remember(coroutineScope) { Component2ViewModel(coroutineScope) }
}