Chanzmao ʕ•ᴥ•ʔ Bear Blog

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
}

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>
)

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)
    }
}

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()

5. Split an Oversized ViewModel

class MainViewModel : ViewModel() {
    val headerState = ...
    val searchState = ...
    val contentState = ...
}
class HeaderViewModel : ViewModel() {
    val state = ...
}

class ContentViewModel : ViewModel() {
    val state = ...
}

@Composable
fun MainScreen(
    header: HeaderViewModel,
    content: ContentViewModel
) {
    HeaderSection(header.state)
    ContentSection(content.state)
}

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)

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)
}

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

Large UiState
     │
     ├─ Actual UI state
     ├─ Mutually exclusive state
     ├─ UI-local state
     ├─ Derived state
     └─ Large data

Articles Based on This Post

#Android #Flow #Jetpack Compose