Beyond MVVM: Hierarchical State Management with Molecule and Compose

Christian Gaisl
8 min readOct 26, 2023

--

In this article, we will explore some shortcomings of typical mobile app architectures and how a hierarchical approach to state management could enhance them.

Android ViewModels have been the first area of contact with the concept of state management for many of us, especially those who started with Android. Over time, there have been many different iterations and acronyms like MVC (Model View Controller), MVP (Model View Presenter), MVVM (Model View ViewModel), and MVI (Model View Intent). Some are easier to wrap your head around than others, but fundamentally, they all work to solve the same problem: Separating UI code from presentation logic.

These architectures are built around the concept that one ViewModel/Controller/Presenter is responsible for one screen. This approach works particularly well in the mobile space since screens in a mobile app are typically relatively small and independent from each other. However, as many of us found out the hard way, this assumption only holds in some scenarios. Here are some examples you might have run into in your project:

  • Complex User Journeys: A sign-up or payment flow where user input from one screen might need to be referenced on a different screen.
  • Inter Screen Dependencies: A Mail app where you simultaneously show the inbox and message detail screens and want the message detail to change when clicking on a message in the inbox.
  • Screens with lots of data: Think of a dashboard screen on a tablet with many different options and configurations.

In my experience, I've seen these situations tackled in one of three ways:

  • Have a ViewModel for each screen and build a custom data transfer object through which these ViewModels exchange data. This could be a globally available object or a service made available to these ViewModels within that user journey via dependency injection.
  • Create a single massive ViewModel that gets shared between all screens in a user journey or every component in a dashboard.
  • Have the ViewModels talk through the domain layer. For instance, an onboarding user journey could create an empty user object in the database, and subsequent screens could reference the database for data on that user.

Depending on your specific needs, any of those approaches might work for you, but none feel right. Creating a custom data transfer object feels like a hack and could be hard to understand and error-prone. Having a single ViewModel be responsible for a whole user flow feels like violating the single responsibility principle. And given the uncertainty of a mobile app's lifecycle, mixing presentation logic into your business logic sounds like a bug waiting to happen.

Hierarchical State Management: A Deeper Solution

What if there was a way to build up our state hierarchically, from smaller, single-responsibility, independently testable components? We could have a single ViewModel for a giant screen or user flow, which would still be manageable. Any state shared between two components would live in a common parent component.

In the UI world, this is nothing new. Fundamentally, even a vast dashboard boils down to a single View. What makes this possible is the modular nature of declarative UI frameworks like Compose UI, SwiftUI, Flutter, or React. We break down UI components into smaller sections and reusable components and compose our finished design from those. This hierarchical structure can scale infinitely while still being easy to maintain.

In the state management world, managing the state hierarchically is less commonplace. I believe this is for two reasons:

  • A lack of necessity: classic MVVM / MVC / MVP / MVI has worked well for most app use cases.
  • A lack of approachable tools: In the UI world, tools like Compose UI, SwiftUI, Flutter, or React do all the heavy lifting for us. When the state changes somewhere in the hierarchy, we expect the framework to update all components dependent on that state to update automagically. Until recently, tools that offered similar functionality in the state management world had to introduce new concepts and had a steep learning curve.

Let's briefly talk about two existing tools for managing state hierarchically on mobile before we dive into my favorite solution to this problem.

  • Redux: Redux is a state management solution primarily used in React web apps. Its basic idea is to forego local state almost entirely and consolidate the whole app's state into a single store. We can hierarchically structure our central state and create separate reducers and middleware for each feature. However, this is a significant break from how mobile developers are used to working, and the learning curve can be steep. As your code base grows, managing the relationships between actions, reducers, middleware, and the central state can become more complex and challenging. The risk of muddling up responsibilities between features is also high.
  • Workflow: Workflow is a state management library from Square that introduces a new state management container called Workflow. A Workflow is similar to a ViewModel, producing models for UI to consume. But in addition to that, each Workflow has inputs (pops) and outputs, making it possible to have parent-child hierarchies between Workflows and building up a hierarchy of Workflows for a screen or user journey. Workflow comes with its own runtime that keeps the state of your tree of Workflows in sync.

Workflow sounds like what we are looking for, but using it is not straightforward. The Workflow API introduces new concepts to learn, and it may take time to determine why Workflow's API is designed like that. After wrapping my head around the problem that the library solves, mainly that of keeping a tree of reusable state containers up to date, its API makes perfect sense. The problem is more complex than it might seem. While Workflow offers a robust solution, we can do even better.

Molecule

A few years after Workflow was released, another library was released by Square/Block/Cashapp: Molecule. It fundamentally solves the same problem Workflow solves for us, mainly that of keeping the hierarchical state in sync. Its approach, however, is radically different.

Instead of creating its own runtime to keep its state tree in sync, it utilizes the Compose Compiler. Yes, you heard that right, the same Compose Compiler that Compose UI uses.

Google's Compose project, as you know it, consists of two components: The Compose compiler and Compose UI. The compiler creates and keeps the view tree and its state in sync. Compose UI merely takes that tree and turns it into UI.

The Molecule library achieves two things for us. It allows us to use the Compose compiler without needing a dependency on Compose UI (even in Kotlin Multiplatform). And it gives us a way to turn the output of a @Composable function into a StateFlow.

Fundamentally, this allows us to build our presentation logic and state with @Composable functions. We can use techniques we already know from building UI in other declarative UI frameworks. If you come from Compose UI, you'll feel right at home. With techniques like state hoisting, we can break down our state into smaller, more manageable components and make every component independent, reusable, and testable.

Mail app example

Let's look at how this could be useful for a typical email client. I've created a GitHub repository with an example implementation. The example runs on Android, but the state management code is written with Kotlin Multiplatform and can also run on iOS or other platforms.

The UI consists of an inbox on the left and the currently selected email on the right.

We could split the Mail Screen's state into an Inbox state and a Content state. The two of them share a state, the currently selected message. I use Workflow's "Rendering" term, which puts state and actions into a single model. Here's what the models look like:

data class InboxItemRendering(
val selected: Boolean,
val subject: String,
val sender: String,
val onClick: () -> Unit
)

data class InboxRendering(
val InboxItemRenderings: List<InboxItemRendering>,
)

data class ContentRendering(
val subject: String,
val sender: String,
val body: String,
)

data class MailScreenRendering(
val list: InboxRendering,
val detail: ContentRendering?,
)

And here is how we produce those renderings. Notice how both the inbox and the content get produced independently with the state of the currently selected mail living in the parent @Composable that combines the two.

@Composable
fun inboxRendering(
selectedMailId: Int?,
onSelectMail: (Int) -> Unit,
): InboxRendering {
var mails: List<Mail> by remember { mutableStateOf(emptyList()) }


LaunchedEffect(Unit) {
mails = MailRepository.getAllMail()
}


return InboxRendering(
InboxItemRenderings = mails.map { mail ->
mail.toInboxItemRendering(
selected = mail.id == selectedMailId
) {
onSelectMail(mail.id)
}
}
)
}


@Composable
fun contentRendering(mailId: Int): ContentRendering {
var mail: Mail? by remember { mutableStateOf(null) }


LaunchedEffect(mailId) {
mail = null
mail = MailRepository.loadMail(mailId)
}


return mail?.let {
ContentRendering.Loaded(
subject = it.subject,
sender = it.sender,
body = it.body,
)
} ?: ContentRendering.Loading
}


@Composable
fun mailScreenRendering(): MailScreenRendering {
var selectedMailId: Int? by remember { mutableStateOf(null) }


return MailScreenRendering(
list = inboxRendering(selectedMailId) {
selectedMailId = it
},
detail = selectedMailId?.let { contentRendering(it) }
)
}

There is still a platform-specific need to tie our state management to a screen's lifecycle. On Android, we can use our trusted ViewModel, which also gives us the benefit of surviving configuration changes:

class MailViewModel : ViewModel() {
val renderings: StateFlow<MailScreenRendering> = viewModelScope
.launchMolecule(mode = RecompositionMode.Immediate) {
mailScreenRendering()
}
}

Another example would be an onboarding user journey. There would still exist a single state container responsible for the whole user journey, but the state of each screen would be composed of independent @Composables. Any state shared between two screens can be hoisted out and hosted on a higher level @Composable, just like it would be if we were building UI. Any changes to that state will propagate to its dependant @Composables automagically.

Another excellent application for this tool would be a complex dashboard screen. This kind of screen's presentation logic and state creation can be broken down into single-responsibility @Composables for each widget. Any inter-widget interactions can be hoisted out into a higher level @Composable. With this approach, your dashboard can be as large as you want while maintaining a simple, modular, and testable code base.

Conclusion

Molecule can be a great tool in modeling state for complex user journeys, inter-screen dependencies, and screens with lots of state. Its use of the Compose compiler makes modeling and breaking down our state into manageable @Composables intuitive and easy to use.

And that's not even Molecule's main selling point! The main feature of Molecule is its ability to create a reactive state from reactive streams without having to chain them together in an unholy mess of stream operators. Compose's built-in functions like remember, LaunchedEffect, collectAsState, and mutableStateOf allow us to untangle the flatMaps, scans, debounces, and zips of yesteryear.

And did I mention that Molecule is fully Kotlin Multiplatform compatible? I am planning to use it extensively in my upcoming projects. Have you tried Molecule in your projects? Let me know what you think in the comments!

Have you enjoyed this article? Feel free to give me a clap 👏, write a comment, and follow me here on Medium to catch all my latest articles. Thanks 👋

--

--