A few years ago, a brand new declarative framework, Jetpack Compose, was a new architecture brought to the Android world. At the beginning we ...

In the last two years of using Jetpack Compose we had few iterations of the MVI architecture. As we sharpened our solution (opens in a new tab) to suit us best and we think it is kinda different from most of the architectures, we thought to share it with community.
The simplest MVI architecture for Compose has 3 "states" available for ViewModel <-> View communication: State, Action, Effect.
class UserAccountViewModel : ViewModel() {β
private val _viewState = MutableStateFlow(State.createInitial())
val viewState: Flow<State> = _viewState
private val _action = MutableStateFlow<Action>(Action.Idle)
fun setAction(action: Action) {
viewModelScope.launch {
_action.update { action }
}
}
private val _effect = MutableSharedFlow<Effect>()
val effect: Flow<Effect> = _effect
private fun emitEffect(effect: Effect) {
viewModelScope.launch {
_effect.emit(effect)
}
}
init {
viewModelScope.launch {
_action.collectLatest { onAction(it) }
}
}
private fun onAction(action: Action) {
when (action) {
Action.AddPoints -> _viewState.update { it.copy(points = it.points + 1) }
Action.Idle -> Unit
Action.SubtractPoints -> _viewState.update { it.copy(points = it.points - 1) }
Action.GoBack -> emitEffect(Effect.GoBack)
Action.DoSth -> doSth()
is Action.ChangeUsername -> _viewState.update { it.copy(username = action.username) }
}
}
private fun doSth() {
// Heavy code
}
data class State(val username: String, val points: Long) {
companion object {
fun createInitial() = State("", 0L)
}
}
sealed class Action {
data object Idle : Action()
data object AddPoints : Action()
data object SubtractPoints : Action()
data object GoBack : Action()
data object DoSth : Action()
data class ChangeUsername(val username: String) : Action()
}
sealed class Effect {
data object GoBack : Effect()
}
}
You may ask "that's right, what's wrong with that?". Theoretically, nothing. We have our 3 state architecture. Unfortunately, there are some issues that become real in larger applications.
Do not use _viewState.value = _viewState.copy() method! It might occur with state not changing for some cases and lead to losing part or even whole of your state. Always use update function for MutableStateFlow.
The best solution here derives from SOLID code, Interface segregation principle (I) (opens in a new tab). To avoid keeping everything in one place, let's create an interface for that, or maybe even two of them.
interface UserAccountContract :β
BaseScreenContract<UserAccountContract.State, UserAccountContract.Action, UserAccountContract.Effect> {
data class State(val username: String, val points: Long) {
companion object {
fun createInitial() = State("", 0L)
}
}
sealed class Action {
data object Idle : Action()
data object AddPoints : Action()
data object SubtractPoints : Action()
data object GoBack : Action()
data object DoSth : Action()
data class ChangeUsername(val username: String) : Action()
}
sealed class Effect {
data object GoBack : Effect()
}
}interface UserAccountScreenContract<State, Action, Effect> {β
val viewState: StateFlow<State>
val effect: Flow<Effect>
fun setAction(action: Action)
}
So, what did we do here? We simply took boilerplate code and put it in the interfaces. How does the packed solution look like?
class UserAccountViewModel : ViewModel(), UserAccountContract {β
private val _viewState = MutableStateFlow(UserAccountContract.State.createInitial())
override val viewState: StateFlow<UserAccountContract.State> = _viewState
private val _action = MutableStateFlow<UserAccountContract.Action>(UserAccountContract.Action.Idle)
override fun setAction(action: UserAccountContract.Action) {
viewModelScope.launch {
_action.update { action }
}
}
private val _effect = MutableSharedFlow<UserAccountContract.Effect>()
override val effect: Flow<UserAccountContract.Effect> = _effect
private fun emitEffect(effect: UserAccountContract.Effect) {
viewModelScope.launch {
_effect.emit(effect)
}
}
init {
viewModelScope.launch {
_action.collectLatest { onAction(it) }
}
}
private fun onAction(action: UserAccountContract.Action) {
when (action) {
UserAccountContract.Action.AddPoints -> _viewState.update { it.copy(points = it.points + 1) }
UserAccountContract.Action.Idle -> Unit
UserAccountContract.Action.SubtractPoints -> _viewState.update { it.copy(points = it.points - 1) }
UserAccountContract.Action.GoBack -> emitEffect(UserAccountContract.Effect.GoBack)
UserAccountContract.Action.DoSth -> doSth()
is UserAccountContract.Action.ChangeUsername -> _viewState.update { it.copy(username = action.username) }
}
}
private fun doSth() {
// Heavy code
}
}
In our case it was just 18 lines of code less, but in more complex screens with way more Actions and Effects, it would be even more beneficial. With our new interfaces, we will also have it easier to create new ViewModels.
We did tell you before, that having State as just one object might be hard to maintain and read. Solution to that, is to create each State's value a separate field and use something called snapshotFlow (opens in a new tab). This function will update originally put there object (in our case a State) every time a single included field changes.
private val username = mutableStateOf("")β
private val points = mutableLongStateOf(0L)
private val _viewState = snapshotFlow {
UserAccountContract.State(
username = username.value,
points = points.longValue
)
}
override val viewState: StateFlow<UserAccountContract.State> = _viewState.stateIn(
viewModelScope,
SharingStarted.Eagerly,
UserAccountContract.State.createInitial()
)
Here we have modified our State, so it will be updated for us automatically every time either points or username fields will change.
private fun onAction(action: UserAccountContract.Action) {β
when (action) {
UserAccountContract.Action.AddPoints -> points.longValue++
UserAccountContract.Action.Idle -> Unit
UserAccountContract.Action.SubtractPoints -> points.longValue--
UserAccountContract.Action.GoBack -> emitEffect(UserAccountContract.Effect.GoBack)
UserAccountContract.Action.DoSth -> doSth()
is UserAccountContract.Action.ChangeUsername -> username.value = action.username
}
}
And now, we can see that modifying state looks like simple changing class internal fields values (which is, by the way)! Thanks to this, we do have cleaner state change handling, better readability, especially for those that does not know our code and we can avoid issue with wrong state changing timing since snapshotFlow handles that on it's own.
Alright, Action is a proxy...but what to do with them? That's simple...delete them all! As you might already think, if an Action is proxy to either a function or emitting effect (that as well should be a function), let's go straight functions! To achieve that, we have to modify our contracts a little bit.
interface BaseScreenContract<State, Effect> {β
val viewState: StateFlow<State>
val effect: Flow<Effect>
}interface UserAccountContract :β
BaseScreenContract<UserAccountContract.State, UserAccountContract.Effect> {
data class State(val username: String, val points: Long) {
companion object {
fun createInitial() = State("", 0L)
}
}
fun addPoints()
fun subtractPoints()
fun goBack()
fun doSth()
fun changeUsername(username: String)
sealed class Effect {
data object GoBack : Effect()
}
}
As you can see, we've already changed actions to separated functions. We've also lost one state that is not needed anymore, Idle. How does it look like put together?
class UserAccountViewModel : ViewModel(), UserAccountContract {β
private val username = mutableStateOf("")
private val points = mutableLongStateOf(0L)
private val _viewState = snapshotFlow {
UserAccountContract.State(
username = username.value,
points = points.longValue
)
}
override val viewState: StateFlow<UserAccountContract.State> = _viewState.stateIn(
viewModelScope,
SharingStarted.Eagerly,
UserAccountContract.State.createInitial()
)
private val _effect = MutableSharedFlow<UserAccountContract.Effect>()
override val effect: Flow<UserAccountContract.Effect> = _effect
private fun emitEffect(effect: UserAccountContract.Effect) {
viewModelScope.launch {
_effect.emit(effect)
}
}
override fun addPoints() {
points.longValue++
}
override fun subtractPoints() {
points.longValue--
}
override fun goBack() {
emitEffect(UserAccountContract.Effect.GoBack)
}
override fun doSth() {
// Heavy code
}
override fun changeUsername(username: String) {
this.username.value = username
}
}
What changed here is that we no longer have nor action field or function to set it, we do not have to listen to action changes on init and we have simple and clean actions in form of functions. This solution might add a few more seconds to checking what user can do with our screen, but overall ViewModel's readability and operability gives us much more benefits. Also, we've lost some lines in comparison to the view model from previous section, so even less code!
We've got ourselves a pretty clean code. Now let's check how we can use it and integrate it with screen. At first, let's create contract and view model for our screen.
interface UserAccountContract :β
BaseScreenContract<UserAccountContract.State, UserAccountContract.Effect> {
data class State(val username: String, val points: Long) {
companion object {
fun createInitial() = State("", 0L)
}
}
fun addPoints()
fun subtractPoints()
fun goBack()
fun changeUsername(username: String)
sealed class Effect {
data object GoBack : Effect()
}
}class UserAccountViewModel : ViewModel(), UserAccountContract {β
private val username = mutableStateOf("")
private val points = mutableLongStateOf(0L)
private val _viewState = snapshotFlow {
UserAccountContract.State(
username = username.value,
points = points.longValue
)
}
override val viewState: StateFlow<UserAccountContract.State> = _viewState.stateIn(
viewModelScope,
SharingStarted.Eagerly,
UserAccountContract.State.createInitial()
)
private val _effect = MutableSharedFlow<UserAccountContract.Effect>()
override val effect: Flow<UserAccountContract.Effect> = _effect
private fun emitEffect(effect: UserAccountContract.Effect) {
viewModelScope.launch {
_effect.emit(effect)
}
}
override fun addPoints() {
points.longValue++
}
override fun subtractPoints() {
points.longValue--
}
override fun goBack() {
emitEffect(UserAccountContract.Effect.GoBack)
}
override fun changeUsername(username: String) {
this.username.value = username
}
}
Now let's create a small screen that will fit our needs.
@Composableβ
fun UserAccountScreen(contract: UserAccountContract, goBack: () -> Unit) {
BaseScreen(
contract = contract,
onEffect = { effect ->
when (effect) {
UserAccountContract.Effect.GoBack -> goBack()
}
},
) { state ->
UserAccountComposable(
state = state,
addPoints = contract::addPoints,
subtractPoints = contract::subtractPoints,
changeUsername = contract::changeUsername,
showError = contract::showError
)
}
}
@Composable
private fun UserAccountComposable( state: UserAccountContract.State, addPoints: () -> Unit, subtractPoints: () -> Unit, changeUsername: (String) -> Unit, showError: () -> Unit ) {
Column {
Column(
verticalArrangement = Arrangement.spacedBy(32.dp),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
Username(username = state.username, changeUsername = changeUsername)
Points(points = state.points, addPoints = addPoints, subtractPoints = subtractPoints)
Button(onClick = showError) {
Text(text = "Show error")
}
}
}
}
@Composable
private fun Username(username: String, changeUsername: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(text = "Welcome, $username!")
OutlinedTextField(
value = username,
onValueChange = changeUsername,
label = { Text(text = "Username") },
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun Points(points: Long, addPoints: () -> Unit, subtractPoints: () -> Unit) {
Column {
Text(text = "You've got $points SnowPoints!")
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp)
) {
OutlinedIconButton(
onClick = addPoints,
shape = CircleShape,
modifier = Modifier.size(48.dp)
) {
Icon(imageVector = Icons.Outlined.Add, contentDescription = "Add points")
}
OutlinedIconButton(
onClick = subtractPoints,
shape = CircleShape,
modifier = Modifier.size(48.dp)
) {
Icon(imageVector = Icons.Outlined.Delete, contentDescription = "Subtract points")
}
}
}
}
What happened just now? As we assume, simple compose code is understandable, we will explain only our specific changes.
As parts 1 & 2 are pretty understandable, we guess the magic happens in the BaseScreen function, right? Not exactly. Let's check it out.
@Composableβ
fun <State, Effect, Contract : BaseScreenContract<State, Effect>> BaseScreen( contract: Contract, onEffect: (Effect) -> Unit, content: @Composable (state: State) -> Unit, ) {
val state = contract.viewState.collectAsState()
content(state.value)
LaunchedEffect(Unit) {
contract.effect.onEach(onEffect).launchIn(this)
}
}
As you can see there is nothing much happening there.
Code is pretty simple and small but helps us with some boilerplate code that we would need to create for each screen.
The BaseScreen function should always be used only for the most top composable for each screen, so there would be only one of each state and effect collectors!
A video showing how the application works using the architecture presented in the article.
Our new architecture gives us also one more advantage. Almost every app has some sort of error handling. We deal it in a various ways, one of them is handling errors globally in each screen. Let's see how we can do it with our BaseScreenContract and BaseScreen.
interface BaseScreenContract<State, Effect> {β
val viewState: StateFlow<State>
val effect: Flow<Effect>
val errorState: StateFlow<ErrorState?>
get() = MutableStateFlow<ErrorState?>(null)
}
As you can see, we've simply added new field that we will be listening to for any error occurrence.
enum class ErrorDialogType { COMMON }β
data class ErrorState(
@StringRes val title: Int,
@StringRes val description: Int,
val onDismiss: () -> Unit,
val onTryAgain: () -> Unit,
val type: ErrorDialogType = ErrorDialogType.COMMON,
)
fun MutableStateFlow<ErrorState?>.dismiss() {
value = null
}
fun MutableStateFlow<ErrorState?>.createErrorState( onDismiss: () -> Unit, onTryAgainAction: () -> Unit, @StringRes title: Int = R.string.error_common_title, @StringRes description: Int = R.string.error_common_description, type: ErrorDialogType = ErrorDialogType.COMMON, ) =
if (this.value == null)
ErrorState(title, description, onDismiss, onTryAgainAction, type)
else
this.value?.copy(onTryAgain = onTryAgainAction)
Now we have an error state class with some utility methods.
In the next step, we have to override our errorState inside UserAccountViewModel and add a function to show our error.
private val _errorState = MutableStateFlow<ErrorState?>(null)β
override val errorState: StateFlow<ErrorState?> = _errorState
override fun showError() {
_errorState.update {
_errorState.createErrorState(
title = R.string.error_common_title,
description = R.string.error_common_description,
onDismiss = _errorState::dismiss,
onTryAgainAction = {
_errorState.dismiss()
viewModelScope.launch {
delay(500)
showError()
}
},
type = ErrorDialogType.COMMON
)
}
}
Remember to add fun showError() to the UserAccountContract!
To show error dialog now we have to create it. We've prepared a small view as a sample of such dialog.
@Composableβ
fun ErrorDialog(errorState: ErrorState) {
Dialog(onDismissRequest = errorState.onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.White, MaterialTheme.shapes.large)
.padding(16.dp),
) {
Text(
text = stringResource(id = errorState.title),
fontWeight = FontWeight.Bold,
fontSize = TextUnit(24f, TextUnitType.Sp)
)
Text(
text = stringResource(id = errorState.description),
modifier = Modifier.padding(top = 8.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(top = 24.dp)
) {
Button(
onClick = errorState.onDismiss,
modifier = Modifier.weight(1f)
) {
Text(text = "Dismiss")
}
OutlinedButton(
onClick = errorState.onTryAgain,
modifier = Modifier.weight(1f)
) {
Text(text = "Try again")
}
}
}
}
}
Now we have to someone be able to show it on our screen. That's also pretty simple. Let's check what we're talking about. What are we going to do is simply add checking for error state change. To do so, we have to add collecting erorrState in our BaseScreen and check if it's not null. Now, with every recomposition Compose will check if error is not null and show appropriate Dialog.
To show dialog and our screen properly, remember to wrap them in a single Box! Thanks to that, dialog will be simply shown at the center of the screen.
@Composableβ
fun <State, Effect, Contract : BaseScreenContract<State, Effect>> BaseScreen( contract: Contract, onEffect: (Effect) -> Unit, content: @Composable (state: State) -> Unit, ) {
val state = contract.viewState.collectAsState()
Box {
content(state.value)
val error = contract.errorState.collectAsState().value
if (error != null) {
when (error.type) {
ErrorDialogType.COMMON -> ErrorDialog(error)
}
}
}
LaunchedEffect(Unit) {
contract.effect.onEach(onEffect).launchIn(this)
}
}
Last step would be to add this button to our screen, but to not make this article too long and to not drown it in already wherever existing code we will assume it's been done so we'll just show video with the solution.
A video showing how the application error dialog works.
That's it! We have reached the end. Thank you for reading this article and we hope it will help you make your Jetpack Compose architecture cleaner and funnier to use.
The eCommerce agency behind 100+ high-stakes projects, ready to build yours.