Where to hoist state

In a Compose application, where you hoist UI state depends on whether UI logic or business logic requires it. This document lays out these two main scenarios.

Best practice

You should hoist UI state to the lowest common ancestor between all the composables that read and write it. You should keep state closest to where it is consumed. From the state owner, expose to consumers immutable state and events to modify the state.

The lowest common ancestor can also be outside of the Composition. For example, when hoisting state in a ViewModel because business logic is involved.

This page explains this best practice in detail and a caveat to keep in mind.

Types of UI state and UI logic

Below there are definitions for types of UI state and logic that are used throughout this document.

UI state

UI state is the property that describes the UI. There are two types of UI state:

  • Screen UI state is what you need to display on the screen. For example, a NewsUiState class can contain the news articles and other information needed to render the UI. This state is usually connected with other layers of the hierarchy because it contains app data.
  • UI element state refers to properties intrinsic to UI elements that influence how they are rendered. A UI element may be shown or hidden and may have a certain font, font size, or font color. In Android Views, the View manages this state itself as it is inherently stateful, exposing methods to modify or query its state. An example of this are the get and set methods of the TextView class for its text. In Jetpack Compose, the state is external to the composable, and you can even hoist it out of the immediate vicinity of the composable into the calling composable function or a state holder. An example of this is ScaffoldState for the Scaffold composable.

Logic

Logic in an application can be either business logic or UI logic:

  • Business logic is the implementation of product requirements for app data. For example, bookmarking an article in a news reader app when the user taps the button. This logic to save a bookmark to a file or database is usually placed in the domain or data layers. The state holder usually delegates this logic to those layers by calling the methods they expose.
  • UI logic is related to how to display UI state on the screen. For example, obtaining the right search bar hint when the user has selected a category, scrolling to a particular item in a list, or the navigation logic to a particular screen when the user clicks a button.

UI logic

When UI logic needs to read or write state, you should scope the state to the UI, following its lifecycle. To achieve this, you should hoist the state at the correct level in a composable function. Alternatively, you can do so in a plain state holder class, also scoped to the UI lifecycle.

Below is a description of both solutions and explanation of when to use which.

Composables as state owner

Having UI logic and UI element state in composables is a good approach if the state and logic is simple. You can leave your state internal to a composable or hoist as required.

No state hoisting needed

Hoisting state isn't always required. State can be kept internal in a composable when no other composable need to control it. In this snippet, there is a composable that expands and collapses on tap:

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

The variable showDetails is the internal state for this UI element. It's only read and modified in this composable and the logic applied to it is very simple. Hoisting the state in this case therefore wouldn't bring much benefit, so you can leave it internal. Doing so makes this composable the owner and single source of truth of the expanded state.

Hoisting within composables

If you need to share your UI element state with other composables and apply UI logic to it in different places, you can hoist it higher in the UI hierarchy. This also makes your composables more reusable and easier to test.

The following example is a chat app that implements two pieces of functionality:

  • The JumpToBottom button scrolls the messages list to the bottom. The button performs UI logic on the list state.
  • The MessagesList list scrolls to the bottom after the user sends new messages. UserInput performs UI logic on the list state.
Chat app with a JumpToBottom button and scroll to bottom on new messages
Figure 1. Chat app with a JumpToBottom button and scroll to bottom on new messages

The composable hierarchy is as follows:

Chat composable tree
Figure 2. Chat composable tree

The LazyColumn state is hoisted to the conversation screen so the app can perform UI logic and read the state from all composables that require it:

Hoisting LazyColumn state from the LazyColumn to the ConversationScreen
Figure 3. Hoisting LazyColumn state from the LazyColumn to the ConversationScreen

So finally the composables are:

Chat composable tree with LazyListState hoisted to ConversationScreen
Figure 4. Chat composable tree with LazyListState hoisted to ConversationScreen

The code is as follows:

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

LazyListState is hoisted as high as required for the UI logic that has to be applied. Since it is initialized in a composable function, it is stored in the Composition, following its lifecycle.

Note that lazyListState is defined in the MessagesList method, with the default value of rememberLazyListState(). This is a common pattern in Compose. It makes composables more reusable and flexible. You can then use the composable in different parts of the app which might not need to control the state. This is usually the case while testing or previewing a composable. This is exactly how LazyColumn defines its state.

Lowest common ancestor for LazyListState is ConversationScreen
Figure 5. Lowest common ancestor for LazyListState is ConversationScreen

Plain state holder class as state owner

When a composable contains complex UI logic that involves one or multiple state fields of a UI element, it should delegate that responsibility to state holders, like a plain state holder class. This makes the composable's logic more testable in isolation, and reduces its complexity. This approach favors the separation of concerns principle: the composable is in charge of emitting UI elements, and the state holder contains the UI logic and UI element state.

Plain state holder classes provide convenient functions to callers of your composable function, so they don't have to write this logic themselves.

These plain classes are created and remembered in the Composition. Because they follow the composable's lifecycle, they can take types provided by the Compose library such as rememberNavController() or rememberLazyListState().

An example of this is the LazyListState plain state holder class, implemented in Compose to control the UI complexity of LazyColumn or LazyRow.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState encapsulates the state of the LazyColumn storing the scrollPosition for this UI element. It also exposes methods to modify the scroll position by for instance scrolling to a given item.

As you can see, incrementing a composable's responsibilities increases the need for a state holder. The responsibilities could be in UI logic, or just in the amount of state to keep track of.

Another common pattern is using a plain state holder class to handle the complexity of root composable functions in the app. You can use such a class to encapsulate app-level state like navigation state and screen sizing. A complete description of this can be found in the UI logic and its state holder page.

Business logic

If composables and plain state holders classes are in charge of the UI logic and UI element state, a screen level state holder is in charge of the following tasks:

  • Providing access to the business logic of the application that is usually placed in other layers of the hierarchy such as the business and data layers.
  • Preparing the application data for presentation in a particular screen, which becomes the screen UI state.

ViewModels as state owner

The benefits of AAC ViewModels in Android development make them suitable for providing access to the business logic and preparing the application data for presentation on the screen.

When you hoist UI state in the ViewModel, you move it outside of the Composition.

State hoisted to the ViewModel is stored outside of the Composition.
Figure 6. State hoisted to the ViewModel is stored outside of the Composition.

ViewModels aren't stored as part of the Composition. They're provided by the framework and they're scoped to a ViewModelStoreOwner which can be an Activity, Fragment, navigation graph, or destination of a navigation graph. For more information on ViewModel scopes you can review the documentation.

Then, the ViewModel is the source of truth and lowest common ancestor for UI state.

Screen UI state

As per the definitions above, screen UI state is produced by applying business rules. Given that the screen level state holder is responsible for it, this means the screen UI state is typically hoisted in the screen level state holder, in this case a ViewModel.

Consider the ConversationViewModel of a chat app and how it exposes the screen UI state and events to modify it:

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

Composables consume the screen UI state hoisted in the ViewModel. You should inject the ViewModel instance in your screen-level composables to provide access to business logic.

The following is an example of a ViewModel used in a screen-level composable. Here, the composable ConversationScreen() consumes the screen UI state hoisted in the ViewModel:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

Property drilling

“Property drilling” refers to passing data through several nested children components to the location where they’re read.

A typical example of where property drilling can appear in Compose is when you inject the screen level state holder at the top level and pass down state and events to children composables. This might additionally generate an overload of composable functions signatures.

Even though exposing events as individual lambda parameters could overload the function signature, it maximizes the visibility of what the composable function responsibilities are. You can see what it does at a glance.

Property drilling is preferable over creating wrapper classes to encapsulate state and events in one place because this reduces the visibility of the composable responsibilities. By not having wrapper classes you’re also more likely to pass composables only the parameters they need, which is a best practice.

The same best practice applies if these events are navigation events, you can learn more about that in the navigation docs.

If you have identified a performance issue, you may also choose to defer reading of state. You can check the performance docs to learn more.

UI element state

You can hoist UI element state to the screen level state holder if there is business logic that needs to read or write it.

Continuing the example of a chat app, the app displays user suggestions in a group chat when the user types @ and a hint. Those suggestions come from the data layer and the logic to calculate a list of user suggestions is considered business logic. The feature looks like this:

Feature that displays user suggestions in a group chat when the user types `@` and a hint
Figure 7. Feature that displays user suggestions in a group chat when the user types @ and a hint

The ViewModel implementing this feature would look as follows:

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

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessage is a variable storing the TextField state. Every time the user types in new input, the app calls business logic to produce suggestions.

suggestions is screen UI state and is consumed from Compose UI by collecting from the StateFlow.

Caveat

For some Compose UI element state, hoisting to the ViewModel might require special considerations. For example, some state holders of Compose UI elements expose methods to modify the state. Some of them might be suspend functions that trigger animations. These suspend functions can throw exceptions if you call them from a CoroutineScope that is not scoped to the Composition.

Let’s say the app drawer’s content is dynamic and you need to fetch and refresh it from the data layer after it’s closed. You should hoist the drawer state to the ViewModel so you can call both the UI and business logic on this element from the state owner.

However, calling DrawerState's close() method using the viewModelScope from Compose UI causes a runtime exception of type IllegalStateException with a message reading “a MonotonicFrameClock is not available in this CoroutineContext”.

To fix this, use a CoroutineScope scoped to the Composition. It provides a MonotonicFrameClock in the CoroutineContext that is necessary for the suspend functions to work.

To fix this crash, switch the CoroutineContext of the coroutine in the ViewModel to one that is scoped to the Composition. It could look like this:

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

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

Learn more

To learn more about state and Jetpack Compose, consult the following additional resources.

Samples

Codelabs

Videos