Architecting your Compose UI

In Compose the UI is immutable—there's no way to update it after it's been drawn. What you can control is the state of your UI. Every time the state of the UI changes, Compose recreates the parts of the UI tree that have changed. Composables can accept state and expose events—for example, a TextField accepts a value and exposes a callback onValueChange that requests the callback handler to change the value.

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

Because composables accept state and expose events, the unidirectional data flow pattern fits well with Jetpack Compose. This guide focuses on how to implement the unidirectional data flow pattern in Compose, how to implement events and state holders, and how to work with ViewModels in Compose.

Unidirectional data flow

A unidirectional data flow (UDF) is a design pattern where state flows down and events flow up. By following unidirectional data flow, you can decouple composables that display state in the UI from the parts of your app that store and change state.

The UI update loop for an app using unidirectional data flow looks like this:

  • Event: Part of the UI generates an event and passes it upward, such as a button click passed to the ViewModel to handle; or an event is passed from other layers of your app, such as indicating that the user session has expired.
  • Update state: An event handler might change the state.
  • Display state: The state holder passes down the state, and the UI displays it.

Figure 1. Unidirectional data flow.

Following this pattern when using Jetpack Compose provides several advantages:

  • Testability: Decoupling state from the UI that displays it makes it easier to test both in isolation.
  • State encapsulation: Because state can only be updated in one place and there is only one source of truth for the state of a composable, it's less likely that you'll create bugs due to inconsistent states.
  • UI consistency: All state updates are immediately reflected in the UI by the use of observable state holders, like StateFlow or LiveData.

Unidirectional data flow in Jetpack Compose

Composables work based on state and events. For example, a TextField is only updated when its value parameter is updated and it exposes an onValueChange callback—an event that requests the value to be changed to a new one. Compose defines the State object as a value holder, and changes to the state value trigger a recomposition. You can hold the state in a remember { mutableStateOf(value) } or a rememberSaveable { mutableStateOf(value) depending on how long you need to remember the value for.

The type of the TextField composable's value is String, so this can come from anywhere—from a hardcoded value, from a ViewModel, or passed in from the parent composable. You don't have to hold it in a State object, but you need to update the value when onValueChange is called.

Define composable parameters

When defining the state parameters of a composable you should keep the following questions in mind:

  • How reusable or flexible is the composable?
  • How do the state parameters affect this composable's performance?

To encourage decoupling and reuse, each composable should hold the least amount of information possible. For example, when building a composable to hold the header of a news article, prefer passing in only the information that needs to be displayed, rather than the entire news article:

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}

Sometimes, using individual parameters also improves performance—for example, if News contains more information than just title and subtitle, whenever a new instance of News is passed into Header(news), the composable will recompose, even if title and subtitle haven't changed.

Consider carefully the number of parameters you pass in. Having a function with too many parameters decreases the ergonomics of the function, so in this case grouping them up in a class is preferred.

Events in Compose

Every input to your app should be represented as an event: taps, text changes, and even timers or other updates. As these events change the state of your UI, the ViewModel should be the one to handle them and update the UI state.

The UI layer should never change state outside of an event handler because this can introduce inconsistencies and bugs in your application.

Prefer passing immutable values for state and event handler lambdas. This approach has the following benefits:

  • You improve reusability.
  • You ensure that your UI doesn't change the value of the state directly.
  • You avoid concurrency issues because you make sure that the state isn't mutated from another thread.
  • Often, you reduce code complexity.

For example, a composable that accepts a String and a lambda as parameters can be called from many contexts and is highly reusable. Suppose that the top app bar in your app always displays text and has a back button. You can define a more generic MyAppTopAppBar composable that receives the text and the back button handle as parameters:

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                    Icons.Filled.ArrowBack,
                    contentDescription = localizedString
                )
            }
        },
        // ...
    )
}

ViewModels, states, and events: an example

By using ViewModel and mutableStateOf, you can also introduce unidirectional data flow in your app if one of the following is true:

  • The state of your UI is exposed via observable state holders, like StateFlow or LiveData.
  • The ViewModel handles events coming from the UI or other layers of your app and updates the state holder based on the events.

For example, when implementing a sign-in screen, tapping on a Sign in button should cause your app to display a progress spinner and a network call. If the login was successful, then your app navigates to a different screen; in case of an error the app shows a Snackbar. Here's how you would model the screen state and the event:

The screen has four states:

  • Signed out: when the user hasn't signed in yet.
  • In progress: when your app is currently trying to sign the user in by performing a network call.
  • Error: when an error occurred while signing in.
  • Signed in: when the user is signed in.

You can model these states as a sealed class. The ViewModel exposes the state as a State, sets the initial state, and updates the state as needed. The ViewModel also handles the sign-in event by exposing an onSignIn() method.

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(UiState.SignedOut)
    val uiState: State<UiState>
        get() = _uiState

    // ...
}

In addition to the mutableStateOf API, Compose provides extensions for LiveData, Flow, and Observable to register as a listener and represent the value as a state.

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(UiState.SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState

    // ...
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
    // ...
}

Learn more

To learn more about architecture in Jetpack Compose, consult the following resources:

Samples