Harnessing the Trifecta of State: State Management with Jetpack Compose on Android — Part 2/4

Christian Gaisl
6 min readSep 23, 2023

--

In one of my previous articles, we explored the high-level concepts of state management and the trifecta of state in mobile apps. The trifecta of state is that any screen can be broken down into state, actions, and side effects. This article will explore these concepts in action with an example screen written with Jetpack Compose on Android.

Our example screen: A headline, a text field, and a button that opens the system dial dialog with the phone number.

Let’s first look at a simple implementation of our example screen. We will analyze some pain points and then determine how to improve them.

@Composable
fun PhoneDialerScreenBaseline() {
val username: String by flow { emit(loadUsernameFromNetwork()) }.collectAsState(initial = "")
var phoneNumber by remember { mutableStateOf("") }

Column(modifier = Modifier.padding(16.dp)) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Hello, $username"
)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TextField(
modifier = Modifier.fillMaxWidth(),
value = phoneNumber,
onValueChange = {
// simple input validation
if (it.length <= 12) {
phoneNumber = it
}
}
)

Spacer(modifier = Modifier.height(24.dp))

Button(
modifier = Modifier.fillMaxWidth(),
onClick = dialPhoneNumber(phoneNumber),
) {
Text("Call")
}
}
}
}

@Composable
private fun dialPhoneNumber(phoneNumber: String): () -> Unit =
with(LocalContext.current) {
{ startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phoneNumber"))) }
}

Let’s focus on some things we can improve upon in this example:

Loading username directly from the network:

  • What if we want to cache the username locally?
  • What if we want to provide a default username?
  • What if we want to use a different network depending on the environment?

Phone number as a local state:

  • What if we want to pre-fill the phone number field?

Input validation inside of the Composable:

  • How can we test the validation?
  • What if we want to reuse it elsewhere?

Those problems stem from a common root: a lack of separation of concerns. The Composable is figuring out what and how to display it simultaneously. The solution is to let the Composable focus on how to display content and figure out the content side of things somewhere else.

The Trifecta of State

The trifecta of state. Any screen can be broken down into state, actions, and side effects.

We can break down any screen into state, actions, and side effects. To let our Composable focus on the visual side of things, we need to turn it into a state consumer that merely displays whatever state it is fed and delegates actions elsewhere.

State Consumer

Let’s figure out what the state in our example would look like:

The illustration shows that the state on this screen consists of the username and the phone number input.

data class PhoneDialerScreenState(
val username: String,
val phoneNumber: String,
)

The screen also contains two actions:

interface PhoneDialerScreenActions {
fun inputPhoneNumber(number: String)
fun onDialButtonPress()
}

We can now simplify our original screen’s code:

@Composable
fun PhoneDialerScreenContent(
state: PhoneDialerScreenState,
actions: PhoneDialerScreenActions,
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Hello, ${state.username}"
)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TextField(
modifier = Modifier.fillMaxWidth(),
value = state.phoneNumber,
onValueChange = actions::inputPhoneNumber
)

Spacer(modifier = Modifier.height(24.dp))

Button(
modifier = Modifier.fillMaxWidth(),
onClick = actions::onDialButtonPress,
) {
Text("Call")
}
}
}
}

Our new screen can now entirely focus on the UI’s visual aspects. It is also straightforward to preview in various states and device sizes.

@Preview
@Composable
fun PhoneDialerScreenPreview() {
PhoneDialerScreenContent(
state = PhoneDialerScreenState(
username = "Christian",
phoneNumber = "1234567890",
),
actions = object : PhoneDialerScreenActions {
override fun inputPhoneNumber(number: String) {}
override fun call() {}
}
)
}

State Modifier

Now, let’s figure out how actually to produce the state. We need to create a class that produces and modifies our state. It also needs to be aware of the lifecycle of our screen, meaning it needs to retain the state across recompositions and have access to a coroutine scope scoped to the lifecycle of our Composable. The way to do this on Android is through a ViewModel.

The primary responsibilities of our ViewModel are producing our state and processing our actions.

class PhoneDialerScreenViewModel: ViewModel(), PhoneDialerScreenActions {
val state = MutableStateFlow(PhoneDialerScreenState(/*initial state*/))

fun loadUsername() {
// pretend we're loading the username from a database
state.value = state.value.copy(username = "Christian")
}

override fun inputPhoneNumber(number: String) {
state.value = state.value.copy(phoneNumber = number)
}

override fun onDialButtonPress() {
// TODO (we'll discuss this in the next section)
}
}

Putting everything together

Now that we have both a state consumer and a state modifier, we must somehow glue them together. On Android, by using the viewModel() function from androidx.lifecycle:lifecycle-viewmodel-compose, this is actually really easy:

@Composable
fun PhoneDialerScreen() {
val viewModel = viewModel<PhoneDialerScreenViewModel>()
val state by viewModel.state.collectAsState()

LaunchedEffect(Unit) {
viewModel.loadUsername()
}

PhoneDialerScreenContent(
state = state,
actions = viewModel,
)
}

Side effects

At this point, we have a neat separation of concerns. Our Composable can entirely focus on the visuals, while our ViewModel can handle all the state modifying concerns. Where things get a little bit murky is when platform-specific functionality comes into play.

In our case, we can open the system phone dialer with our button press. That’s a functionality that is provided by the system context, something that is deliberately not available in our ViewModel. One of the reasons for that is that that would make writing unit tests for our ViewModel much more complicated.

We could add a boolean property to our state that triggers our functionality whenever it changes. However, “openSystemDialActivity” is hardly what one would intuitively call a state.

Simply calling the desired functionality in the Composable directly instead of triggering our ViewModel’s “onDialButtonPress” action could work in simple cases. But what if we want to do some validation first? Or what if we want to trigger different functionality depending on the current state? We would bring all the logic we tried so hard to keep out of the Composable back into it. And testing it would also become a convoluted mess.

The solution to our problem is the concept of side effects. In addition to state, our ViewModel now also emits side effects.

sealed class PhoneDialerScreenSideEffect {
data class Dial(val phoneNumber: String): PhoneDialerScreenSideEffect()
}

Here’s our updated VielModel:

class PhoneDialerScreenViewModel: ViewModel(), PhoneDialerScreenActions {
val state = MutableStateFlow(PhoneDialerScreenState(/*initial state*/))
val sideEffects = MutableSharedFlow<PhoneDialerScreenSideEffect>()

fun loadUsername() {
// pretend we're loading the username from a database
state.value = state.value.copy(username = "Christian")
}

override fun inputPhoneNumber(number: String) {
state.value = state.value.copy(phoneNumber = number)
}

override fun onDialButtonPress() {
viewModelScope.launch {
sideEffects.emit(PhoneDialerScreenSideEffect.Dial(state.value.phoneNumber))
}
}
}

We can then consume and act upon those side effects at the location where we instantiate our ViewModel. Here’s the updated code:

@Composable
fun PhoneDialerScreen() {
val viewModel = viewModel<PhoneDialerScreenViewModel>()
val state by viewModel.state.collectAsState()

val context = LocalContext.current

LaunchedEffect(Unit) {
viewModel.loadUsername()

viewModel.sideEffects.onEach { sideEffect ->
when (sideEffect) {
is PhoneDialerScreenSideEffect.Dial -> {
context.dial(sideEffect.phoneNumber)
}
}
}.launchIn(this)
}

PhoneDialerScreenContent(
state = state,
actions = viewModel,
)
}

private fun Context.dial(phoneNumber: String) {
startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phoneNumber")))
}

Testing

Having our logic contained in our ViewModel makes testing a breeze. We have to instantiate our ViewModel, trigger an action, and check the resulting state and side effects:

class PhoneDialerScreenViewModelTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
Dispatchers.setMain(StandardTestDispatcher())
}

@Test
fun phoneDialerScreenViewModelTest() = runTest {
val viewModel = PhoneDialerScreenViewModel()

// Capture side effects with cashapp's turbine library
viewModel.sideEffects.test {

// Trigger action
viewModel.loadUsername()

// Assert state
assertEquals("Christian", viewModel.state.value.username)


// Trigger action
viewModel.inputPhoneNumber("1234567890")

// Assert state
assertEquals("1234567890", viewModel.state.value.phoneNumber)


// Trigger action
viewModel.onDialButtonPress()

// Assert side effects
assertEquals(PhoneDialerScreenSideEffect.Dial("1234567890"), awaitItem())
}
}
}

Full Code

The full code for this example can be found on my GitHub. https://github.com/cgaisl/StateComposeArticle/

Conclusion

In this article, we looked at a basic implementation of a Jetpack Compose Screen on Android and analyzed some common pain points. We harnessed the trifecta of state, explored in one of my previous articles, to address some of those points, which lead to a more maintainable and testable code. I hope this gave us a more concrete understanding of state management and the trifecta of state. Remember that the general concepts are platform agnostic and can easily be adapted for different platforms.

In the next article of this series, we will discuss an example utilizing the concepts in this article on iOS with SwiftUI.

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 👋

--

--