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
andset
methods of theTextView
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 isScaffoldState
for theScaffold
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.
The composable hierarchy is as follows:
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:
So finally the composables are:
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.
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.
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:
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
Recommended for you
- Note: link text is displayed when JavaScript is off
- Save UI state in Compose
- Lists and grids
- Architecting your Compose UI