Compete in the Jetpack Compose #AndroidDevChallenge for a chance to win one of over 1,000 prizes, including a Google Pixel 5. Learn more

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.

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 LiveData or StateFlow.

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.

To implement a UI that displays "Hello, ${name}" and allows the user to input their name, you write:

@Composable
fun HelloScreen() {
    Column {
        var name by rememberSaveable { mutableStateOf("") }
        Text(text = "Hello, ${name}")
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

A UI that displays "Hello, ${name}" and allows the user to input their name

In the example above, HelloScreen holds its own state. That means that the callers don't have access to it. If you want to use the state for something else (for example, as part of a login network call) or to display it in another composable, you need to move the state to a higher level.

When a composable holds its own state, it can make the composable hard to reuse and hard to test, and it keeps the composable tightly coupled to how the state is stored.

To make the composable stateless, you can use state hoisting to move the state to the caller of the composable and use lambdas to represent events:

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })

    if(name.isNotEmpty){
       ...
    }
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp), style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = { onNameChanged(it) },
            label = { Text("Name") }
        )
    }
}

This implements a unidirectional flow, where state goes downward from HelloScreen to HelloContent and events go upward from HelloContent to HelloScreen:

  1. Event: onNameChange is called in response to the user typing a character.
  2. Update state: onNameChange handles processing, then sets the state of name.
  3. Display state: The value of name changes, which is observed by Compose in mutableStateOf. Then HelloContent, as it observes the state changes, recomposes to describe the UI based on the new value of name.

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 your architecture

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.ChevronLeft)
            }
        },
        ...
    )
}

State holder in Compose

Sometimes, keeping a simple API is best, but depending on what you need to build and how reusable a composable needs to be, you might decide that using an internal state is not enough. To make your composable more reusable, you can hoist its state to the composable that calls it.

Hoist an internal state

Suppose that you have an ExpandingCard that collapses and expands with an animation when the user clicks a button:

ExpandedCard composable animates between collapsed and expanded

The ExpandedCard contains an expanded internal state that defines whether the body should be shown or not:

@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }

    // Describe the card for the current state of expanded.
    Card {
        Column {
            Text(text = title)
            // Content of the card depends on the current value of expanded.
            if (expanded) {
                Text(text = body)
                // Change expanded in response to click events.
                IconButton(onClick = { expanded = false }) {
                    Icon(Icons.Default.ExpandLess, contentDescription = "Collapse")
                }
            } else {
                // Change expanded in response to click events.
                IconButton(onClick = { expanded = true }) {
                    Icon(Icons.Default.ExpandMore, contentDescription = "Expand")
                }
            }
        }
    }
}

This API is straightforward and the behavior is in the implementation details. However, it does not allow you to reuse the composable and set the expanded state from the caller side.

By keeping expanded as an internal state, you limit the flexibility of the composable. For example, you can't set the default to expanded (true) in one usage of the composable and to collapsed (false) in another, and you can't persist the user's preference across app recreation.

Here are some ways to handle this:

Option 1: Controlled value and change event

If you check out the documentation of the material composables (from androidx.compose.material), you'll see that they all follow the same pattern: expose a value to set and a function to call when the value has changed. You can do the same for your ExpandingCard:

@Composable
fun ExpandingCard(
  title: String,
  body: String,
  expanded: Boolean,
  onExpandedChange: (Boolean) -> Unit
) {
  // ...
}

The advantage of this approach is that the composable is now stateless, and the caller can control the expanded state. The downside is that the caller always has to explicitly declare the expanded state, and there's no way to have a default, uncontrolled behavior.

If you don't need to have a default behavior, then use this approach.

Option 2: Hoist an expanded state class

Another option is to wrap the expanded state in a class and hold it in a mutableStateOf. This class can then be a parameter of ExpandingCard, with a default value:

class ExpandingCardState(expanded: Boolean) {
  var expanded: Boolean by mutableStateOf(expanded)
}
@Composable fun ExpandingCard(
  title: String,
  body: String,
  state: ExpandingCardState = remember { ExpandingCardState(false) }
) {
  // ...
}

This approach provides the following benefits:

  • The caller can control the state as needed.
  • Your composable can use a default value.
  • The implementation can scale more easily.

ViewModels and state

ViewModels are the recommended state holder for composables that are high up in the Compose UI tree or composables that are destinations in the Navigation library. ViewModels survive configuration changes, so they allow you to encapsulate state and events related to the UI without having to deal with the activity or fragment lifecycle that hosts your Compose code.

Your ViewModels should expose the state in an observable holder, such as LiveData or StateFlow. When the state object is read during a composition, the current recompose scope of the composition is automatically subscribed to updates of that state object.

You can have one or more observable state holders—each of them should hold the state for parts of your screen that are conceptually related and that change together. That way, you preserve a single source of truth, even if the state is used in multiple composables.

For example, suppose that you have a UI that displays "Hello, ${name}" and allows the user to input their name.

You can use LiveData and ViewModel in Jetpack Compose to implement unidirectional data flow:

class HelloViewModel : ViewModel() {

    // LiveData holds state which is observed by the UI.
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    // onNameChanged is an event we're defining that the UI can invoke.
    // (events flow up from UI)
    fun onNameChanged(newName: String) {
        _name.value = newName
    }
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    // By default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen().

    // name is the _current_ value of [helloViewModel.name]
    // with an initial value of "".
    // observeAsState returns a State<T> that will trigger a recomposition
    // of the composables that read the state whenever it changes.
    val name: String by helloViewModel.name.observeAsState("")

    Column {
        Text(text = "Hello, $name")
        TextField(
            value = name,
            onValueChange = { helloViewModel.onNameChanged(it) },
            label = { Text("Name") }
        )
    }
}

ViewModels, states, and events: an example

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.

By using ViewModel and LiveData, 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 LiveData as the observable state holder implementation.
  • 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 LiveData, sets the initial state, and updates the state as needed. The ViewModel also handles the sign-in event by exposing an onSignIn() method. The following code changes the state to InProgress, triggers the sign-in, and then handles the result by changing the state again.

class SignInViewModel(/* ... */): ViewModel() {

    // Initial state is SignedOut.
    private val _state = MutableLiveData<SignInState>(SignInState.SignedOut)
    val state: LiveData<SignInState> = _state

   fun onSignIn(email: String, password: String) {
        viewModelScope.launch {
            _state.value = SignInState.InProgress
            val result = userRepository.signIn(email, password)
            if(result.isSuccess){
                _state.value = SignInState.SignedIn
            } else {
                _state.value = SignInState.Error(result.error)
            }
        }
    }
}

sealed class SignInState {
    object SignedOut : SignInState()
    object InProgress : SignInState()
    data class Error(val error: String) : SignInState()
    object SignedIn : SignInState()
}

The SignInContent composable receives the state and the event handler as parameters. The state is used to decide what to display, the minimum state data needed is passed to each composable, and the event handler is passed to the Sign in button:

@Composable
fun SignIn(viewModel: HomeViewModel = viewModel()) {
    val viewState by viewModel.state.observeAsState()

    Surface(Modifier.fillMaxSize()) {
        SignInContent(
            state = viewState,
            onSignIn = viewModel::onSignIn,
            modifier = Modifier.fillMaxSize()
        )
    }
}

@Composable()
fun SignInContent(state: SignInState, onSignIn: (email: String, password: String) -> Unit){
   /*.... */
    when(state){
        SignInState.SignedOut -> SignIn(onSignIn)
        InProgress -> SignInProgress()
        is SignInState.Error -> SignInError(state.error)
        SignInState.SignedIn -> /* ... */
    }
  /*...*/
}

How to architect a screen

There are two ways of thinking about your Compose screens, depending on how you break down your UI into composable pieces: bottom-up or top-down. Suppose that you need to build a login screen.

The sign-in area of the UI includes an email
       field, a password field, and a sign-in button.
A typical login screen

Consider how you would approach the sign-in area of the UI. Here are the requirements:

  • The Sign in button needs to be enabled only when the email and password fields contain text in a specific format (for example, the text in the email field contains an @ sign).
  • When the user clicks the Sign in button, the app needs to log the user in with the provided email and password.

In a top-down approach, you start with the SignIn composable. The composable can receive a reference to a SignInViewModel that handles the event that is triggered when the user clicks the Sign in button:

@Composable
fun SignIn(viewModel: SignInViewModel = viewModel()) {

    Button(
        onClick = { viewModel.onSignIn(emailState.text, passwordState.text) }
    ) {
        Text(text = "Sign in")
    }
}

You could implement the email and password as internal states in SignIn, but you already know you also need to implement a sign-up screen that will also require fields for email, password, and password confirmation.

Because you want to reuse composables as much as possible, you should create Email and Password composables. Because the state of the Sign in button depends on the state of the email and password fields, the SignIn composable should hold and remember their states:

@Composable
fun SignIn(
    viewModel: SignInViewModel = viewModel(),
    onSignInSubmitted: (email: String, password: String) -> Unit,
) {
        val viewState by viewModel.state.observeAsState()
        Email(viewState.emailState)

        Password(
            label = stringResource(id = R.string.password),
            passwordState = viewState.passwordState
        )

        Button(
            onClick = { viewModel.onSignIn(emailState.text, passwordState.text) },
            enabled = emailState.isValid && passwordState.isValid
        ) {
         Text(text = "Sign in")
        }

}

The state would then contain the least amount of information that is needed to implement the composable. For example, a simple EmailState class could look something like this:

class EmailState(private val validator: (String) -> Boolean = { true }) {
    var text: String by mutableStateOf("")

    val isValid: Boolean = validator(text)

    fun showErrors() = !isValid

    open fun getError(): String? {
        return if (showErrors()) {
            emailValidationError(text)
        } else {
            null
        }
    }
}

In a bottom-up approach, you start with the smaller composables first: you notice that the email and password fields are composables that can be reused. Next, you try to figure out which elements you need to hoist higher in the hierarchy. Finally, you implement the composables that use these elements. In this example, that would be the SignIn composable.

Independently of which approach you use, you should end up with a similar UI tree, and the UI tree should mirror the structure of the UI. Use whichever approach is easier for you.

Learn more

To learn more about state, see State and Jetpack Compose and the Using State in Jetpack Compose codelab.