Sitemap

Android Compose — T Composable Architecture Intro(1/4)(Maybe React)

10 min readFeb 2, 2025
Generate Gemini Advanced.

This document is a translation of the Korean article “컴포즈에 사용할 Composable Architecutre 설명(리엑트?)” which discusses a React-inspired approach to Composable Architecture for use in Jetpack Compose.

This article is the first in a series introducing T Composable Architecture, which incorporates React’s concepts of Reducer, UiState, Effect, and Action, and is distinct from the recently popular MVI (Model-View-Intent) pattern.

The “T” stands for the author’s first initial, and the rest stands for Compose and Architecture.

While many believe that MVI is the most suitable for Compose, cycle.js.org, which introduces MVI, explains it as follows:

cycle.js.org — Model-View-Intent

Model-View-Intent (MVI) is reactive, functional, and follows the core idea in MVC. It is reactive because Intent observes the User, Model observes the Intent, View observes the Model, and the User observes the View. It is functional because each of these components is expressed as a referentially transparent function over streams. It follows the original MVC purpose because View and Intent bridge the gap between the user and the digital model, each in one direction.

The key is the fusion of reactive, functional, and MVC. There are differences from the MVI pattern we currently use. The reactive and functional aspects are the same, but considering MVVM and UDF (Unidirectional Data Flow) as more suitable seems preferable.

In this article

  • What is the Composable structure the author envisions?

Understanding Architecture?

The best way to understand architecture is through UDF (Unidirectional Data Flow). Observing the actual data flow and whether it changes actively is React’s core concept.

What is needed to understand Data flow?(ln — Korean)

The data flow in a typical Android architecture can be represented as follows:

Including UiState, which is also one of React’s concepts.

Code representing data flow

Let’s explain by writing the code for the above diagram.

Repository Part

Contrary to the diagram, you need to start writing the Repository code to understand which part triggers the subscription and updates reactively.

class SomeRepository(private val someApi: SomeApi) {

suspend fun loadData(): DataEntity =
someApi.load()
}

ViewModel Part

In this part, the call occurs, and the response is processed by coroutine blocking. It is important to note that instead of reactively receiving the response, it is immediately called and immediately returned.

class SomeViewModel(private val someRepository: SomeRepository) {

private val _uiState = MutableStateFlow(SomeUiState())
val uiState = _uiState.asStateFlow()

fun load() = viewModelScope.launch {
val data = someRepository.loadData() // Up to here is a typical call
// Manage stateFlow for reactivity from here
_uiState.value = data.convert() // Update to _uiState after convert
}
}

If you add Effect to ViewModel and use it like a reducer

Currently, StateFlow is used in a reactive code form. Adding MVI’s Effect to this requires the following changes.

sealed interface Effect {
data object Load : Effect
}

If you modify it to receive and process Effect:

class SomeViewModel(private val someRepository: SomeRepository) {

private val _uiState = MutableStateFlow(SomeUiState())
val uiState = _uiState.asStateFlow()

fun reduce(effect: Effect) = viewModelScope.launch {
when (effect) {
is Effect.Load -> {
val data = someRepository.loadData() // Up to here is a typical call
// Manage stateFlow for reactivity from here
_uiState.value = data.convert() // Update to _uiState after convert
}
}
}
}

This code is written simply and may vary depending on the MVI library you use. Some libraries perform the task of returning UiState at the end and passing it to BaseReducer, but this is omitted here.

View Part

@Composable
fun ContentView(
someViewModel: SomeViewModel = viewModels(),
) {
val uiState by someViewModel.uiState.collectAsStateWithLifecycle()

Column {
Button(
onClick = {
someViewModel.reduce(Effect.Load), // Update new data
}
) {
Text(
text = "Button",
)
}

Text(
text = uiState.text, // Update according to UiState
)
}
}

The diagramed part of this code corresponds to the figure you saw above.

MVI or Android Architecture?

In my opinion, MVI and Android architecture are not much different in the big picture.

Android architecture also includes React’s UiState concept, and has been using reactive concepts such as RxJava, LiveData, and Flow for a long time. It used the subscription form for dynamically receiving data flows and updating them.

So what remains? How about adding a part that makes it easier to use?

Before that, it would be good to look at how events are delivered to ViewModel.

Action

Action is a concept used to pass events from the View to the ViewModel. In the MVI pattern, Intent plays a similar role to Action.

The subsequent data flow involves receiving the Action, and the ViewModel can update the UiState according to the Action. The View uses the updated UiState to render the screen.

Conversely, events generated in the ViewModel are passed to the View through Effect. Effect represents one-time events such as displaying a Toast message or navigating screens.

Now, let’s explain how to handle onClick in @Composable functions, divided into three main categories.

1. Method of receiving and calling Higher-order function as a parameter

One of the most commonly used methods. This method cannot be discarded. This method is essential when creating a common component or creating a Component that needs to be reused because you cannot specify an Action.

@Composable
fun SomeItem(
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
) {
// Omitted
}
}

However, this method has the disadvantage of having to pull it up to the place where ViewModel exists and chaining it. You cannot avoid doing this, and you cannot inject ViewModel everywhere.

2. Method of directly passing and calling a calling function like ViewModel as a parameter

If you encounter a Screen in the first method, you can use ViewModel, and in this part, you can call the Higher-order function with the actual function call.

@Composable
fun SomeScreen(
viewModel: SomeViewModel = viewModels(),
) {
Surface(
onClick = {
viewModel.some()
},
) {
// Omitted
}
}

The important thing here is that you should carefully distinguish and write functions as Stateless and Stateful for Preview, and if you write as Stateful without thinking, Preview becomes difficult.

Stateful versus stateless — Official Document Link

3–1. Method of passing Higher-order definition to UiState and calling it

If you don’t know Effect, you can also choose this method. If you don’t know which function to call.

data class SomeUiState(
val onClick: () -> Unit,
)

If you use Item, you have the advantage of being able to call it directly even if you don’t know viewModel.

@Composable
fun SomeView(
someUiState: SomeUiState,
) {
Surface(
onClick = {
someUiState.onClick()
},
) {
// Omitted
}
}

3–2. Method of passing and calling Action (Effect) information to UiState

What if we define Effect and use it this time?

sealed interface Effect {
data object Some : Effect
}

data class SomeUiState(
val effect: Effect,
)

In this method, you need to know viewModel to call it.

@Composable
fun SomeScreen(
someUiState: SomeUiState,
viewModel: SomeViewModel = viewModels(),
) {
Surface(
onClick = {
viewModel.onEffect(someUiState.effect)
},
) {
// Omitted
}
}

This Approach

The four methods listed above are what the author came up with.

Methods 1, 2, and 3–2 have the disadvantage that the viewModel must be known in @Composable, and to bring it up to this point, Higher-order functions must be passed continuously.

In the end, passing Higher-order functions continuously is not bad, but when they increase, there are many parts to manage.

That said, choosing method 3–1 is not a good choice, and although consideration is needed, it seems to be the best choice in terms of development convenience.

T Composable Architecture

  • Action : Effect, which can be the same as Intent mentioned in MVI. The view generates Action.
  • reducer : The part that receives and processes the Action generated from the View in ViewModel. In MVI code, it is the same as processing Intent as a reducer.
  • However, at the end, Action can specify the next Action.
  • This method is a part inspired by Swift-composable architecture — Link.

The rest is about the code part, and the two things to know in this article are as above.

Using T Composable Architecture

Before using it, I drew the entire sequence, which can be divided into flow 1 and flow 2. After processing the event in the Repository, UiState can be updated, or SideEffect can be processed.

For the entire sample code, please refer to the link below.

Sample code — link

Defining Dependency

implementation("tech.thdev:composable-architecture-system:latestversion")

Defining Action

To define Action, inherit and implement CaAction.

sealed interface Action : CaAction {

data object Task : Action // Use object for simple actions

data object LoadData : Action // Use object for simple actions
}

Defining SideEffect

Here, the term SideEffect is used. Inherit and implement CaSideEffect.

sealed interface SideEffect : CaSideEffect {

data object ShowToast : SideEffect // Use object for simple side effects
}

Defining ViewModel

Use Hilt to specify the ViewModel to use as shown below.

Here, it is defined by inheriting reducer().

@HiltViewModel
class MainViewModel @Inject constructor(
flowCaActionStream: FlowCaActionStream,
) : CaViewModel<Action, SideEffect>(flowCaActionStream, Action::class) {

private val _uiState = MutableStateFlow(UiState())
val state = _uiState.asStateFlow()

override suspend fun reducer(action: Action): CaAction =
when (action) {
is Action.Task -> {
_uiState.value = UiState(showPlaceholder = true)
Action.LoadData // next event
}

is Action.LoadData -> {
val loadEnd = // load network
_uiState.value = UiState(text = loadEnd)

sendSideEffect(SideEffect.ShowToast)

CaActionNone // Or return another action
}
// ... other actions
}
}

Using in Activity

Finally, in Activity, it should be operated as follows.

@AndroidEntryPoint
class MainActivity : CaActionActivity() {

private val mainViewModel by viewModels<MainViewModel>()

@Composable
override fun ContentView() {
TComposableArchitectureTheme {
val action = LocalActionOwner.current

Column {
Button(
onClick = action.send(Action.LoadData),
) {
Text(
text = "OnClick",
)
}

Text(
text = uiState.text,
)
}

LaunchedEffect(Unit) {
mainViewModel.loadAction() // Required: Load actions
mainViewModel.action(Action.Task) // Option task
}

mainViewModel.sideEffect.collectAsEvent { // Optional: Handle side effects
when (it) {
SideEffect.ShowToast -> {
Toast.makeText(this@MainActivity, "message", Toast.LENGTH_SHORT).show()
}
// ... other side effects
}
}
}
}
}

Using in Composable

If you use this in Composable,

@Composable
fun SomeScreen(
mainViewModel: MainViewModel = viewModels(),
) {
val uiState by mainViewModel.uiState.collectAsStateWithLifecycle()

SomeScreen(
uiState = uiState,
)

LaunchedEffect(Unit) {
mainViewModel.loadAction() // Required: Load actions
mainViewModel.action(Action.Task) // Option task
}
}

@Composable
fun SomeScreen(
uiState: UiState,
) {
val action = LocalActionOwner.current

Column {
Button(
onClick = action.send(Action.LoadData),
) {
Text(
text = "OnClick",
)
}

Text(
text = uiState.text,
)
}
}

In the part to be implemented, the call to loadAction is forced to be generated according to LaunchEffect().

How to use LocalActionOwner?

This code largely has one internal implementation for processing Action. The rest is the same as general MVVM + React, UiState processing.

What are the advantages of using LocalActionOwner.current?

  • You can use LocalActionOwner.current immediately at the location where you want to use it without passing viewModel and Higher-order functions as parameters every time.

I think that this alone can reduce the tedious boilerplate code in development.

Fortunately, including LocalActionOwner.current does not affect Preview.

Using Next in Action?

And as mentioned earlier, the reducer is implemented to define the next Action.

  • You can filter and detect only events that occur in the current Screen.

It processes only the Action in the screen I have. If necessary, you have to pass the Action defined above.(Usually, that doesn’t happen often.)

  • Even after processing the event in ViewModel, there are times when you need to process another action. There are cases where you must process action B after action A is finished. How should we handle this part? I returned it in a way that TCA uses.
  • This part is very different from returning UiState in MVI.

I made it possible to specify which event to process next by the returned Action, and due to this advantage, when A is finished, it can be passed to B as an event and processed.

There are things that you can call the function again, but it is not easy to connect the next event on the Stream, so I implemented it in this way.

Main Code

The main code is as follows.

The point is that InternalCaAction is written as @Singleton. There can be only one in the app.

SharedFlow is used to unify the same event into one even in N views. The disadvantage is that it can be viewed anywhere. The advantage is that it can be sent anywhere.

@Singleton
class InternalCaAction @Inject constructor() : FlowCaActionStream, CaActionSender {

private val flowCaAction = MutableSharedFlow<CaAction>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

override fun flowAction(): Flow<CaAction> =
flowCaAction.asSharedFlow()
.filter { it != CaActionNone }

override fun send(action: CaAction) {
flowCaAction.tryEmit(action)
}

override fun nextAction(action: CaAction) {
send(action)
}
}

To solve this problem, a filterable code was added to CaViewModel in the middle.

abstract class CaViewModel<ACTION : CaAction, SIDE_EFFECT : CaSideEffect>(
flowCaActionStream: FlowCaActionStream,
actionClass: KClass<ACTION>,
) : ViewModel() {

@VisibleForTesting
val flowAction by lazy(LazyThreadSafetyMode.NONE) {
flowCaActionStream.flowAction()
.filterIsInstance(actionClass) // filter
.map {
reducer(action = it)
}
.onEach {
flowCaActionStream.nextAction(it)
}
}
}

Key Code for Utilizing in Compose

staticCompositionLocalOf was used because there is no reason to update it every time based on the Activity. The article on the scope of the impact was mentioned when writing the webview before, so it is not mentioned here.

Compose Navigation — Solving WebView Recomposition?(Korean)

object LocalCaActionSenderOwner {

private val LocalComposition = staticCompositionLocalOf<CaActionSender?> { null }

val current: CaActionSender?
@Composable
get() = LocalComposition.current

infix fun provides(registerOwner: CaActionSender): ProvidedValue<CaActionSender?> =
LocalComposition provides registerOwner
}

The provides was set up, and it was made into a form that is continuously used.

CompositionLocalProvider(
LocalCaActionSenderOwner provides caActionSender,
) {
ContentView()
}

When using it

val action = LocalActionOwner.current

Button(
onClick = { action.send(Action.Some) }
) {}

As a result, the reason for passing the parameter also disappeared, and it can be used by calling LocalActionOwner.current in the necessary part.

Next Task

The next task is to introduce how to process Alert/Toast/Snackbar using Action. It is necessary to design so that UI customization is possible, and it must be decided whether to process it with Action or separate it.

I will discuss the considerations for this part in the next article.

--

--

No responses yet