Sitemap

(T Composable Architecture — Part 2) I Built a Composable Architecture, But Found Problems? Let’s Improve It.

13 min readApr 15, 2025

In a previous post, I introduced a Composable Architecture. However, after using it, I discovered a few problems and decided to improve it. This post reorganizes the content with those improvements.

I identified two major issues:

  • Automatic next event triggering after Reducer processing in the ViewModel, potentially causing infinite loops.
  • Lifecycle issues when using a singleton for Action stream processing.

This post summarizes the code modifications made to solve these two problems and explores the thought process behind whether there might be even better approaches.

In this post:

  • We’ll identify the structural problems of the existing architecture.
  • Share the problem-solving process and thoughts on better structures.
  • This post doesn’t cover the basics, so referring to the previous post is recommended.

What is Action?

How can we simplify communication between the View and ViewModel? In the Jetpack Compose environment, using CompositionLocal is one way. I applied this concept to create the idea of an Action, allowing event handling to be easily called from anywhere within a Composable function.

To explain why I used a Flow-based Action, let's first look at an example of a typical View-ViewModel communication method.

It’s common practice to pass the ViewModel instance directly as a parameter to a Composable function. However, as the depth or number of Composable functions increases, you need to consider how far to pass the ViewModel. This approach also has the drawback of naturally increasing boilerplate code as the structure becomes more complex.

Function call using ViewModel directly

Kotlin

@Composable
fun SomeScreen(someViewModel: SomeViewModel) {
Button(onClick = { someViewModel.doSomething() })
Button(onClick = { someViewModel.doSomethingTwo() })
Button(onClick = { someViewModel.doSomethingThree() })
}

class SomeViewModel : ViewModel() {
fun doSomething() { /* ... */ }
fun doSomethingTwo() { /* ... */ }
fun doSomethingThree() { /* ... */ }
}

Calling ViewModel functions integrated via sealed interface

sealed interface SomeAction {
data object ActionOne : SomeAction
data object ActionTwo : SomeAction
data class ActionThree(val item: Any) : SomeAction
}

@Composable
fun SomeScreen(someViewModel: SomeViewModel) {
val item = remember { /* ... */ } // Example data
Button(onClick = { someViewModel.dispatch(SomeAction.ActionOne) })
Button(onClick = { someViewModel.dispatch(SomeAction.ActionTwo) })
Button(onClick = { someViewModel.dispatch(SomeAction.ActionThree(item)) })
}
class SomeViewModel : ViewModel() {
fun dispatch(action: SomeAction) {
when (action) {
is SomeAction.ActionOne -> { /* ... */ }
is SomeAction.ActionTwo -> { /* ... */ }
is SomeAction.ActionThree -> { /* ... */ }
}
}
}

My Approach (Using CompositionLocal)

The methods above require continuously passing the ViewModel or creating callback functions like onClick: () -> Unit. The callback approach, in particular, can lead to creating N higher-order functions during event integration.

Therefore, I’m using the method provided by Compose, utilizing CompositionLocal (referred to as Locally scoped in the original link text, but CompositionLocal is the API), to easily access the Action object. (For detailed usage, please refer to the previous post: Explanation of Composable Architecture for Compose)

// Action definition (Example)
sealed interface MyScreenAction : CaAction { // CaAction serves as a marker interface
data object ButtonClick : MyScreenAction
data class TextTyped(val text: String) : MyScreenAction
data class SwitchChanged(val isOn: Boolean) : MyScreenAction
}

// Composable View
@Composable
fun SomeScreen() {
// Get ActionDispatcher via CompositionLocal
val actionDispatcher = LocalActionDispatcher.current
var textState by remember { mutableStateOf("") }
var switchState by remember { mutableStateOf(false) }
Column {
Button(onClick = { actionDispatcher.dispatch(MyScreenAction.ButtonClick) }) {
Text("Click Me")
}
TextField(
value = textState,
onValueChange = {
textState = it
actionDispatcher.dispatch(MyScreenAction.TextTyped(it))
}
)
Switch(
checked = switchState,
onCheckedChange = {
switchState = it
actionDispatcher.dispatch(MyScreenAction.SwitchChanged(it))
}
)
}
}
// ViewModel
class SomeViewModel(
private val flowCaActionStream: FlowCaActionStream // Inject Action stream
) : CaViewModel<MyScreenAction>(flowCaActionStream, MyScreenAction::class) { // Specify the Action type to receive
// Inside CaViewModel, filter and receive only MyScreenAction type events via flowAction
// Implement processing logic for each Action in the reducer method
override suspend fun reducer(action: MyScreenAction) {
when (action) {
is MyScreenAction.ButtonClick -> {
// Button click processing logic
Log.d("SomeViewModel", "Button Clicked")
}
is MyScreenAction.TextTyped -> {
// Text input processing logic
Log.d("SomeViewModel", "Text Typed: ${action.text}")
}
is MyScreenAction.SwitchChanged -> {
// Switch change processing logic
Log.d("SomeViewModel", "Switch Changed: ${action.isOn}")
}
}
}
}

Improvements in Usability

Since the required Action can be called from anywhere using LocalAction.current (or similar CompositionLocal provider), the development convenience can be improved by eliminating the need to continuously pass the ViewModel instance down to child Composables.

Providing appropriate default values or test implementations for CompositionLocal ensures that Preview functionality works without issues. However, to test state changes or interactions of specific UI elements in Preview, it's better to follow the principles of declarative UI by creating Stateless Composables and injecting state and events from the outside.

// Stateless Composable Example
@Composable
fun SomeContent(
text: String,
isSwitchOn: Boolean,
onButtonClick: () -> Unit,
onTextTyped: (String) -> Unit,
onSwitchChange: (Boolean) -> Unit,
modifier: Modifier = Modifier // Recommended to add Modifier
) {
Column(modifier = modifier) {
Button(onClick = onButtonClick) { /* ... */ }
TextField(value = text, onValueChange = onTextTyped)
Switch(checked = isSwitchOn, onCheckedChange = onSwitchChange)
}
}

// Stateful Composable (Connected to ViewModel)
@Composable
fun SomeScreen(viewModel: SomeViewModel = hiltViewModel()) { // Example using Hilt or other DI
val actionDispatcher = LocalActionDispatcher.current // Action dispatcher
// Subscribe to state from ViewModel or manage necessary state here
val textState by viewModel.textState.collectAsState() // Example StateFlow
val switchState by viewModel.switchState.collectAsState() // Example StateFlow
SomeContent(
text = textState,
isSwitchOn = switchState,
onButtonClick = { actionDispatcher.dispatch(MyScreenAction.ButtonClick) },
onTextTyped = { actionDispatcher.dispatch(MyScreenAction.TextTyped(it)) },
onSwitchChange = { actionDispatcher.dispatch(MyScreenAction.SwitchChanged(it)) }
)
}

The disadvantages of this approach (separating Stateless/Stateful and using CompositionLocal) are as follows:

  • Instead of managing all UI interactions within a single reducer function in the ViewModel, state update logic and event dispatch logic might be separated. (This could also be seen as an advantage depending on perspective).
  • When adding a new Action event, logic to handle that event must also be added to the ViewModel's reducer. (This can actually be an advantage when using sealed interface, as it can be enforced at compile time).

However, the advantages are:

  • The responsibility for dispatching events for each UI element becomes clear.
  • Stateless Composables are reusable and easy to test.
  • Accessing the event dispatch interface via CompositionLocal is convenient.

To Summarize

Points I wanted to solve:

  • Why must event transmission between View and ViewModel always be done by directly calling viewModel.someFunction() through the ViewModel instance?
  • Can we reduce the hassle of continuously passing ViewModel instances or callback functions when the depth of Composable functions increases?

Action (CompositionLocal) Introduced for This Reason:

I aimed to reduce boilerplate within Composable functions by providing an Action interface for event handling and an ActionDispatcher via CompositionLocal to easily dispatch them.

However, there were still problems to solve:

  • A clear agreement is needed between the sender (View) and receiver (ViewModel) on exactly which Action type to use and process. If different types are used or missed, it can lead to a critical issue where events are lost and functionality breaks.

To solve this problem and reduce errors during development, I adopted the approach of defining Action using sealed interface. When using sealed interface, the when expression in the ViewModel's reducer forces the implementation of all subtypes, preventing the possibility of missing events at compile time. This also has the advantage of potentially reducing some UI behavior test cases.

Problems Discovered

While designing the architecture with reference to swift-composable-architecture, I found a structure where the Reducer processes a specific action and then triggers subsequent actions sequentially.

Looking at Swift Composable Architecture code examples, the Reduce closure can return .send or other Effects to trigger the next action. (The code below might be from an older TCA syntax; current versions often use the @Reducer macro, etc.)

// TCA Example (Conceptual)
Reduce { state, action in
switch action {
case .buttonTapped:
state.isLoading = true
// Return an effect to perform async work and then trigger another action (.dataLoaded)
return .run { send in
let data = try await apiClient.fetchData()
await send(.dataLoaded(data))
}
case let .dataLoaded(data):
state.isLoading = false
state.data = data
return .none // No further action
// ...
}
}

I wanted to implement this ‘action followed by chained action’ concept in Android using Flow and my custom Action system. However, two main problems arose here:

Issues due to automatic nextAction calls:

  • Designing the system to automatically trigger the next action (nextAction) based on the return value of the ViewModel's reducer function increased the burden on developers, requiring them to understand this behavior precisely. The number of rules to know increased.
  • Critically, there was a risk of falling into an infinite loop if nextAction was specified incorrectly or if there were mistakes in the reducer logic. While not impossible to debug, it was a structure where predictable problems could easily occur due to the code design.

Singleton Action stream and Lifecycle synchronization issues:

  • Using a single Action stream (FlowCaActionStream) as a singleton throughout the app caused problems when a new Activity was launched or when navigating between screens using a Composable Navigation library (like Navigation-Compose).
  • For example, assume there are Activity A and Activity B, each with multiple Composable screens. Even if the user is currently in Activity B, ViewModels belonging to Activity A in the background might still be subscribing to the singleton Action stream. If a specific Action occurs in Activity B, and a ViewModel in Activity A also has filtering logic (filterIsInstance) for that Action type, that Action could be unintentionally processed in Activity A's ViewModel. (While you could defend against this within the reducer logic by checking the current screen state, fundamentally, unnecessary subscription and processing attempts occur.)
  • This can cause synchronization problems, especially when handling Side Effects that can affect the entire app, such as Alerts, Toasts, or Router (screen navigation). This issue is more pronounced because the ViewModel’s lifecycle (viewModelScope) is generally longer than the Composable's lifecycle.

I will explain specifically how these two problems were solved.

Problem 1 — Solving the Potential Infinite Loop

To eliminate the possibility of infinite loops, I modified the parts of the existing CaViewModel's flowAction processing that were problematic.

Original Code (Potential for problems):

abstract class CaViewModel<ACTION : CaAction, SIDE_EFFECT : CaSideEffect>(
private val flowCaActionStream: FlowCaActionStream, // Added 'private' (encapsulation)
actionClass: KClass<ACTION>,
) : ViewModel() {
@VisibleForTesting
val flowAction by lazy(LazyThreadSafetyMode.NONE) {
flowCaActionStream.flowAction()
.filterIsInstance(actionClass) // 1. Filter only Actions this ViewModel should handle
.map { action -> // 2. Call reducer and return its result (Potential issue)
reducer(action = action) // Assuming reducer returns the next Action
}
.onEach { nextActionToDispatch -> // 3. Automatically propagate the next Action returned by map (Problem!)
flowCaActionStream.nextAction(nextActionToDispatch) // Potential infinite loop point
}
// .launchIn(viewModelScope) // Should actually be launched here
}
// Assuming the reducer function returned the next Action
abstract suspend fun reducer(action: ACTION): CaAction? // Example: Return type is the next Action
}

Modified Code:

abstract class CaViewModel<CA_ACTION : CaAction>(
private val flowCaActionStream: FlowCaActionStream,
actionClass: KClass<CA_ACTION>,
) : ViewModel() {
// Flow for Action processing (automatic nextAction logic removed)
@VisibleForTesting
internal val actionProcessingFlow by lazy(LazyThreadSafetyMode.NONE) { // Changed to 'internal' and clarified name
flowCaActionStream.flowAction()
.filterIsInstance(actionClass) // 1. Filter Actions to process
.onEach { action -> // 2. Use onEach instead of map. Just execute reducer for each Action (return value ignored)
reducer(action = action)
}
// 3. Automatic nextAction propagation logic removed
}
// Reducer function now focuses only on Side Effect processing or state changes (no return value)
abstract suspend fun reducer(action: CA_ACTION)
// Added function to call when explicitly wanting to propagate the next Action
protected fun nextAction(action: CaAction) { // Changed to 'protected' to restrict usage to subclasses
flowCaActionStream.nextAction(action)
}
// Actual Flow subscription start/cancel is managed separately (See Lifecycle solution below)
@VisibleForTesting
var actionProcessingJob: Job? = null
}

Summary of Changes:

  • map -> onEach Change: Removed the structure where the reducer function returned the next Action, which was then passed downstream by the map operator. Instead, used onEach to simply execute the reducer function for each Action. This eliminated the possibility of automatic nextAction calls regardless of the reducer's return value.
  • Explicit nextAction Function Added: Changed the approach so that if sequential Action propagation is needed, the developer must explicitly call the nextAction(action) function within the reducer function. This enhances code predictability by ensuring the next Action occurs based on the developer's clear intent, rather than implicit system behavior.

Now, the next Action must be explicitly specified within the reducer like this:

override suspend fun reducer(action: MyScreenAction) {
when (action) {
is MyScreenAction.ButtonClick -> {
// Example: Propagate an Alert display Action if a specific condition is met after button click
if (shouldShowAlert()) {
nextAction(CommonUiAction.ShowAlert("Button Clicked!")) // Explicitly call nextAction
}
}
// ... other Action handling
}
}

This change eliminated the possibility of system-induced infinite loops and made the code flow clearer.

Problem 2 — Solving the Lifecycle Issue

To resolve the Lifecycle synchronization issues arising from using a singleton Action stream (FlowCaActionStream), I introduced a method to start and stop the ViewModel's Action stream subscription based on the Composable's Lifecycle.

Problem Situation: When Activities A and B exist, even while Activity B is visible on screen, ViewModels in the background Activity A might still be subscribing to the Action stream. If an Action occurs in Activity B, it could also be delivered to Activity A's ViewModel. (While type filtering with filterIsInstance works, it becomes a problem if the same type of Action is used across multiple screens.)

Solution: Lifecycle-Aware Subscription Control

We use the Composable’s Lifecycle states (especially ON_RESUME, ON_PAUSE) to start and cancel the subscription (Job) to the ViewModel's Action stream (actionProcessingFlow). For this, I created a Helper Composable function utilizing DisposableEffect and LocalLifecycleOwner.

@Composable
fun LaunchedLifecycleViewModel( // Renamed from ObserveLifecycle in previous version
viewModel: CaViewModel<*> // ViewModel requiring lifecycle management
) {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, viewModel) { // Keys are lifecycleOwner and viewModel
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
// Start Action stream subscription when the screen becomes active
viewModel.startActionProcessing()
}
Lifecycle.Event.ON_PAUSE -> {
// Cancel Action stream subscription when the screen becomes inactive
viewModel.cancelActionProcessing()
}
// ON_DESTROY can be handled by DisposableEffect's onDispose or
// in ViewModel's onCleared
else -> { /* Do nothing for other events */ }
}
}
lifecycleOwner.lifecycle.addObserver(observer)

// Remove Observer when the Composable leaves the Composition (onDispose)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
// Consider calling cancelActionProcessing() here as well if needed
// viewModel.cancelActionProcessing()
}
}
}

And add functions to CaViewModel to start and cancel the Action stream subscription. (Using the internal access modifier prevents direct calls from outside the module.)

abstract class CaViewModel<CA_ACTION : CaAction>(
// ... (Same as previous code)
) : ViewModel() {
// ... (actionProcessingFlow, reducer, nextAction, etc.) ...
@VisibleForTesting
var actionProcessingJob: Job? = null // Job to manage subscription state
// Start Action stream subscription (Called on ON_RESUME)
internal fun startActionProcessing() {
// Prevent duplicate execution if already running
if (actionProcessingJob?.isActive == true) return
// Cancel existing Job just in case
cancelActionProcessing()
// Start subscribing to actionProcessingFlow in viewModelScope
actionProcessingJob = actionProcessingFlow
.launchIn(viewModelScope)
}
// Cancel Action stream subscription (Called on ON_PAUSE)
internal fun cancelActionProcessing() {
actionProcessingJob?.cancel()
actionProcessingJob = null
}
// Ensure Job cancellation when ViewModel is destroyed (onCleared)
override fun onCleared() {
super.onCleared()
cancelActionProcessing()
}
}

How to use the code above?

You need to call the LaunchedLifecycleViewModel function in the top-level Composable of each screen.

@Composable
fun SomeScreen(viewModel: SomeViewModel = hiltViewModel()) {
// Manage ViewModel's Action subscription lifecycle
LaunchedLifecycleViewModel(viewModel = viewModel)
// --- Actual UI ---
// val state by viewModel.uiState.collectAsState()
// SomeContent(...)
// ---
}

Improvement Idea: If calling LaunchedLifecycleViewModel(viewModel) every time is cumbersome, consider an extension function or delegate that automatically includes this logic when obtaining the ViewModel instance. For example:

@Composable
fun Some(viewModel: ViewModel = hiltViewModel().Activate()) { // Author's initial idea
// Your view
}

Or, if extending Hilt directly:

// Conceptual idea (Needs implementation)
@Composable
inline fun <reified VM : CaViewModel<*>> hiltViewModelWithLifecycle(): VM {
val viewModel: VM = hiltViewModel()
LaunchedLifecycleViewModel(viewModel = viewModel) // Changed name here to match helper
return viewModel
}

// Usage example
@Composable
fun SomeScreen(viewModel: SomeViewModel = hiltViewModelWithLifecycle()) {
// LaunchedLifecycleViewModel() call no longer needed
// ... UI ...
}

(Gemini’s Note: Functions like hiltViewModelWithLifecycle call another Composable (LaunchedLifecycleViewModel) within a Composable function. It might be better to separate the ViewModel creation logic and Lifecycle observation logic. Alternatively, handling it in a dedicated entry point Composable could also be considered.)

Considerations:

  • This approach might face criticism that the ViewModel becomes indirectly aware of the UI Lifecycle. However, the timing of start/cancelActionProcessing calls is determined externally (LaunchedLifecycleViewModel), so the ViewModel itself doesn't directly reference Lifecycle objects.
  • There might be overhead from canceling and restarting the Job on every ON_RESUME / ON_PAUSE event. However, this is one effective way to prevent unnecessary Action processing when the screen is not actually visible.

Are the Problems Solved?

Through the changes above, I was able to resolve the two main problems identified in the initial design.

  • Potential Infinite Loop: Solved by removing the automatic nextAction propagation logic after reducer processing and changing to an explicit nextAction function call method.
  • Singleton Action Stream Lifecycle Issue: Solved by introducing the LaunchedLifecycleViewModel Helper Composable that controls the ViewModel's Action stream subscription according to the Composable's Lifecycle, addressing the possibility of unnecessary Action processing in inactive screens.

However, there might always be a better way.

Is There a Better Way to Subscribe?

Instead of creating and canceling a Job with launchIn on every ON_RESUME/ON_PAUSE, we could consider using Flow's stateIn operator.

The stateIn operator converts a Flow into a StateFlow and provides a SharingStarted policy that allows controlling the execution of the upstream Flow based on the presence of subscribers (collectors). For example, using the SharingStarted.WhileSubscribed() policy, the upstream Flow (actionProcessingFlow) would only be active while the StateFlow is being subscribed to by a Composable visible on the screen (e.g., subscribed via collectAsState), and the subscription would automatically be canceled when the screen disappears.

This approach could lead towards a direction similar to the Circuit architecture created by Slack. In Circuit, the Presenter exposes UI State as a Flow, and UI events are sent to the Presenter via a Sink. The execution of logic within the Presenter can ultimately be determined by whether the UI State Flow is being subscribed to.

A post by skydoves (Jaewoong Kim), Loading Initial Data in LaunchedEffect vs. ViewModel, also offers insights into similar ideas by comparing data loading within LaunchedEffect versus using ViewModel's stateIn.

Ultimately, managing data flow centered around State, and controlling the execution of related logic (like Action processing) based on the subscription lifecycle of that state, might be more Compose-friendly and efficient.

In my current design, I wanted to keep state management and event processing separate, so I haven’t fully adopted this approach (like extensively using Circuit or stateIn). However, I think it might eventually evolve into a similar form.

Next

In this post, I summarized the process of identifying two problems in the existing Composable Architecture design and the considerations and choices made to solve them.

The next posts will cover specific features implemented using this architecture.

  • Already in the code, but will cover Alert/Toast implementation.
  • Will also cover the part for handling Router (Activity, Navigation).

I plan to organize these two topics in the next posts.

--

--

No responses yet