This is a common question when you start designing your UI state. Should you have a single state that represents the entire UI or should you have multiple states that represent different parts of the UI? Both approaches can be used simultaneously, depending on the specific situation.
When you have multiple states that are related to each other, it's a good idea to keep them together in a single class. This makes it easier to understand the relationship between the states and how they change together.
This state contains logically related data. The articles
list represent a list of articles which is presented to the user.
Then user can select one of them to see the details.
data class ArticleListUiState(
val articles: List<Article> = emptyList(),
val selectedArticle: Article? = null,
)
class ArticleListViewModel : ViewModel() {
// ViewModel exposes a single UI State
val uiState: StateFlow<ArticleListUiState> = ...
}
When you have states that are not related to each other, it's a good idea to keep them separate in different classes.
These two states have nothing in common. One represents the list of articles and the other represents the user toolbar with some information about currently logged-in user.
data class ArticleListUiState(
val articles: List<Article> = emptyList(),
val selectedArticle: Article? = null,
)
sealed interface UserToolbarUiState {
data object SignedOut : UserToolbarUiState
data class SignedIn(
val userName: String,
val subscriptionType: SubscriptionType,
) : UserToolbarUiState
}
class ArticleListViewModel : ViewModel() {
// ViewModel exposes one state for article list ...
val articleListUiState: StateFlow<ArticleListUiState> = ...
// ... and another state for user toolbar
val userToolbarUiState: StateFlow<UserToolbarUiState> = ...
}
When you design your UI state, you can use a data class, a sealed interface, or a combination of both to represent the state. But be careful, because the choice of the representation can have a significant impact on the complexity of the code which uses this state.
When you have a state that can be in one of several states, the sealed interface is a proper way to represent it.
In this case user always goes from one state to another.
It can be a simple Loading/Success/Failure
or more complex structure.
sealed inteface ArticleListUiState {
data object Loading : ArticleListUiState
data class Success(val articles: List<Article>) : ArticleListUiState
data object Failure : ArticleListUiState
}
When the state contains data that can be modified by the user, it's better to use a flat data class. It simplifies the code which performs state updates.
data class ArticleListUiState {
val articles: List<Article> = emptyList()
val selectedArticle: Article? = null
}
When you need both a sequence of states and a mutable data, you can nest a sealed interface inside a data class.
sealed interface ArticlesLoadingUiState {
data object Loading : ArticlesLoadingUiState
data class Success(val articles: List<Article>) : ArticlesLoadingUiState
data object Failure : ArticlesLoadingUiState
}
data class ArticleListUiState(
val articlesLoadingUiState: ArticlesLoadingUiState = Loading,
val selectedArticle: Article? = null,
)
When some part of the state can me modified we should not put it inside sealed interface. It adds extra complexity to the code which updates the state because we need to check if we are in the correct variant of the sealed interface.
sealed interface ArticleListUiState {
data object Loading : ArticleListUiState
data class Success(
// This one is loaded from other layers
val articles: List<Article>,
// This one is modified by the user
val selectedArticle: Article? = null,
) : ArticleListUiState
data object Failure : ArticleListUiState
}
class ArticleListViewModel : ViewModel() {
private val _uiState = MutableStateFlow<ArticleListUiState>(Loading)
fun selectArticle(article: Article) {
_uiState.value = when (val currentState = _uiState.value) {
// Mutable data inside sealed interface adds unnecessary complexity
is ArticleListUiState.Success -> currentState.copy(selectedArticle = article)
else -> currentState
}
}
}
UI state has to be represented in a observable way, so the Compose get notified about the changes and can update the UI.
There are two main ways to represent the UI state: StateFlow
and Compose State
.
It is up to your preference to choose if you want to use StateFlow
or Compose State
to represent the UI State inside the ViewModel.
State
is simpler solution which can be used as a property delegate.
StateFlow
on the other hand is more powerful with variety of extension functions.
class ArticleListViewModel : ViewModel() {
// You can use StateFlow ...
private val _uiState = MutableStateFlow(ArticleListUiState())
val uiState: StateFlow<ArticleListUiState> = _uiState.asStateFlow()
// ... as well as Compose State
val uiState by mutableStateOf(ArticleListUiState())
private set
}
StateFlow
is thread-safe out of the box. When you use Compose State
you need to make sure that you use a dedicated function when you update the state from a background thread.
// State representation with StateFlow
private val _uiState = MutableStateFlow(ArticleListUiState.Loading)
val uiState = _uiState.asStateFlow()
// Loading data in the background
fun loadData() = viewModelScope.launch(defaultDispatcher) {
runCatching {
articlesRepository.getArticles()
}.onSuccess {
// We can safely update the state from the background thread
_uiState.value = ArticleListUiState.Success(it)
}
}
// State representation with Compose State
var uiState by mutableStateOf(ArticleListUiState.Loading)
private set
// Loading data in the background
fun loadData() = viewModelScope.launch(defaultDispatcher) {
runCatching {
articlesRepository.getArticles()
}.onSuccess {
// We need to wrap the state update in withMutableSnapshot
Snapshot.withMutableSnapshot {
uiState = ArticleListUiState.Success(it)
}
}
}
StateFlow
needs to be collected in the UI and transformed to Compose State
. Collecting can be done in two ways: lifecycle-aware or not.
Standard collectAsState
function keeps collecting the flow even when the UI is in the STOPPED state.
Dedicate collectAsStateWithLifecyce
stops collecting the flow in that case.
@Composable
fun ArticleListScreen() {
val viewModel: ArticleListViewModel = viewModel()
// Lifecycle-aware collection
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Standard collection
val uiState by viewModel.uiState.collectAsState()
}