Taming the Monolithic UiState in Jetpack Compose
Practical Strategies for Breaking Down Complex UI State Without Losing Clarity

1. Model Mutually Exclusive States with a sealed interface
data class UiState(
val isLoading: Boolean,
val error: String?,
val items: List<Item>
)
↓
sealed interface UiState {
data object Loading : UiState
data class Error(val message: String) : UiState
data class Success(val items: List<Item>) : UiState
}
- Instead of combining multiple flags and nullable properties to represent the current state, model mutually exclusive states directly.
Loading,Error, andSuccessare now explicit states, making invalid combinations much harder to represent.
2. Break Flat State into Sub-States
data class UiState(
val title: String,
val query: String,
val items: List<Item>,
val selectedCategory: Category?
)
↓
data class UiState(
val header: HeaderState,
val filter: FilterState,
val list: ListState
)
data class HeaderState(
val title: String
)
data class FilterState(
val query: String,
val category: Category?
)
data class ListState(
val items: List<Item>
)
- Group related properties by responsibility.
- This makes a large
UiStateeasier to navigate and makes state updates more explicit.
3. Move UI-Local State into the Composable
data class UiState(
val items: List<Item>,
val isExpanded: Boolean
)
↓
data class UiState(
val items: List<Item>
)
@Composable
fun Screen(state: UiState) {
var isExpanded by rememberSaveable {
mutableStateOf(false)
}
}
- Not every piece of UI state needs to live in a
ViewModel. - State that is purely local to the UI can stay inside the Composable.
- Examples include temporary expansion state, animation state, focus state, and similar UI-only behavior.
4. Turn Computable Values into Derived State
data class UiState(
val items: List<Item>,
val filteredItems: List<Item>,
val isEmpty: Boolean
)
↓
data class UiState(
val items: List<Item>,
val query: String
)
val filteredItems = state.items
.filter { it.name.contains(state.query) }
val isEmpty = filteredItems.isEmpty()
- If a value can be calculated from existing state, you usually don’t need to store it separately.
- This reduces the amount of state you need to keep in sync.
- In a
ViewModel, use operators such as combine or map to derive state from multiple flows. - In Compose,
derivedStateOfcan be useful when deriving values from Compose state.
5. Split an Oversized ViewModel
class MainViewModel : ViewModel() {
val headerState = ...
val searchState = ...
val contentState = ...
}
-
At first glance, the state already appears to be separated.
-
However, a single
ViewModelis still responsible for everything.↓
class HeaderViewModel : ViewModel() {
val state = ...
}
class ContentViewModel : ViewModel() {
val state = ...
}
@Composable
fun MainScreen(
header: HeaderViewModel,
content: ContentViewModel
) {
HeaderSection(header.state)
ContentSection(content.state)
}
- When a screen has clearly independent responsibilities, splitting the
ViewModelcan prevent both theViewModeland its state from becoming monolithic. - However, don’t create a separate
ViewModelfor every Composable. Split responsibilities when they have genuinely independent lifecycles or responsibilities.
6. Don’t Put Huge Lists Directly into UiState
data class UiState(
val items: List<Item>
)
val uiState = repository.loadAllItems()
↓
data class UiState(
val query: String
)
val items = repository.items()
.cachedIn(viewModelScope)
- When the dataset is large, don’t make the entire dataset part of a monolithic
UiState. - Instead, use a streaming or paging mechanism such as Paging.
- Keep UI state such as search queries and filters separate from large datasets.
7. Split One Large StateFlow into Independent State Flows
data class UiState(
val header: HeaderState,
val search: SearchState,
val content: ContentState
)
val uiState: StateFlow<UiState> = ...
↓
val headerState: StateFlow<HeaderState> = ...
val searchState: StateFlow<SearchState> = ...
val contentState: StateFlow<ContentState> = ...
@Composable
fun Screen(viewModel: MainViewModel) {
Header(viewModel.headerState)
Search(viewModel.searchState)
Content(viewModel.contentState)
}
- If parts of the screen have genuinely independent state, there is no need to force everything into a single
StateFlow<UiState>. - Each UI section can observe only the state it actually needs.
- However, splitting every property into a separate
StateFlowcan make the architecture harder to understand, so use this selectively.
A Simple Way to Decide
Massive UiState
│
├─ Loading / Error / Success?
│ → sealed interface
│
├─ Too many unrelated fields?
│ → Sub-State
│
├─ UI-only state?
│ → remember / rememberSaveable
│
├─ Computable value?
│ → Derived State
│
├─ ViewModel is too large?
│ → Split ViewModels
│
├─ Large dataset?
│ → Paging
│
└─ Independent state?
→ Split StateFlows
The Key Idea
- A large UiState is not necessarily a problem by itself.
- The real problem is often that different kinds of state have been mixed together.
Large UiState
│
├─ Actual UI state
├─ Mutually exclusive state
├─ UI-local state
├─ Derived state
└─ Large data
- The first four approaches focus on reconsidering what belongs in state.
- The last three focus on splitting the structure and responsibilities of that state.
- The goal is not simply to make
UiStatesmaller. The goal is to make the state model easier to understand, reason about, and maintain.