# Compose State Decomposition — Monolithic → Split Sub-State Pattern

## Problem

A single `UiState` data class holding 15+ fields causes cascade recomposition — ANY field change (e.g., watchlist toggle) recomposes the entire screen including heavy static content (backdrops, season lists, cast chips).

## Solution: Split into stable sub-state data classes

Each visual section gets its own immutable `data class`. The ViewModel updates only the changed sub-state via `copy()`. Compose's snapshot system detects identical references for unchanged sub-states and skips recomposition.

## Concrete Pattern (from NexStream DetailsScreen)

### Before — 973-line monolith

```kotlin
data class DetailsUiState(
    val meta: MetaItem? = null,
    val streams: List<Stream> = emptyList(),
    val rankedStreams: List<RankedStream> = emptyList(),
    val bestStream: Stream? = null,
    val justWatchOffers: List<JustWatchOffer> = emptyList(),
    val cacheBadge: CacheBadge? = null,
    val isInWatchlist: Boolean = false,
    val watchProgress: WatchProgress? = null,
    val isLoading: Boolean = true,
    val error: String? = null,
    val showAllSources: Boolean = false,
    val seasons: List<MetaItem> = emptyList(),
    val episodes: List<MetaVideo> = emptyList(),
    val activeSeason: Int = 1,
    val relatedTitles: List<MetaPreview> = emptyList(),
)
```

Every state change (watchlist toggle, season change, stream arrival) → full-screen recomposition.

### After — 5 stable sub-states

```kotlin
data class HeroState(
    val backgroundUrl: String? = null,
    val posterUrl: String? = null,
    val title: String = "",
    val metadataLine: String = "",
    val genres: List<String> = emptyList(),
    val cacheBadge: CacheBadge? = null,
    val playButtonLabel: String = "▶ Play",
    val isInWatchlist: Boolean = false,
    val hasTrailer: Boolean = false,
    val hasStreams: Boolean = false,
)

data class SourceListState(
    val bestStream: Stream? = null,
    val streams: List<Stream> = emptyList(),
    val rankedStreams: List<RankedStream> = emptyList(),
    val cacheBadge: CacheBadge? = null,
)

data class SeasonsState(
    val seasons: List<MetaItem> = emptyList(),
    val episodes: List<MetaVideo> = emptyList(),
    val activeSeason: Int = 1,
)

data class JustWatchState(
    val offers: List<JustWatchOffer> = emptyList(),
)

data class RelatedState(
    val titles: List<MetaPreview> = emptyList(),
)

data class DetailsUiState(
    val meta: MetaItem? = null,
    val isLoading: Boolean = true,
    val error: String? = null,
    val hero: HeroState = HeroState(),
    val sourceList: SourceListState = SourceListState(),
    val seasons: SeasonsState = SeasonsState(),
    val justWatch: JustWatchState = JustWatchState(),
    val related: RelatedState = RelatedState(),
)
```

### ViewModel update — only changed sub-state gets new reference

```kotlin
// Watchlist toggle — only hero recomposes
fun toggleWatchlist() {
    _uiState.update { it.copy(hero = it.hero.copy(isInWatchlist = !it.hero.isInWatchlist)) }
}

// Season change — only seasons recompose
fun changeSeason(season: Int) {
    _uiState.update { it.copy(seasons = it.seasons.copy(activeSeason = season, episodes = filtered)) }
}

// Full data load — all sub-states get new references
_uiState.update { it.copy(
    hero = HeroState(...),
    sourceList = SourceListState(...),
    seasons = SeasonsState(...),
    justWatch = JustWatchState(...),
    related = RelatedState(...),
)}
```

### Screen — each section reads its own sub-state

```kotlin
@Composable
fun DetailsScreen(uiState: DetailsUiState) {
    Column {
        DetailsHeroSection(uiState.hero)        // stable unless hero ref changes
        DescriptionSection(uiState.meta)         // stable after initial load
        CastSection(uiState.meta)                // stable after initial load
        JustWatchSection(uiState.justWatch)      // stable unless offers change
        StreamSection(uiState.sourceList)        // stable unless streams change
        if (uiState.isShow) {
            SeasonsSection(uiState.seasons)      // stable unless seasons change
        }
        MoreLikeThisSection(uiState.related)     // stable unless related change
    }
}
```

## Why This Works

Kotlin `data class.copy()` creates a new object. When the ViewModel calls `it.copy(hero = it.hero.copy(isInWatchlist = ...))`:
- `hero` gets a new `HeroState` reference
- `sourceList`, `seasons`, `justWatch`, `related` keep the SAME reference

Compose's `SnapshotStateObserver` checks for reference equality (`===`). Sections whose sub-state reference hasn't changed are skipped entirely — no recomposition, no recomputation of modifiers, no redraw.

## When to Split

| Condition | Recommended split |
|---|---|
| Screen has 3+ distinct visual sections | Split each section into its own sub-state |
| One section updates independently (watchlist toggle) | Extract that section's state |
| Section contains expensive composables (backdrops, grids) | Give it its own state so it recomposes rarely |
| Initial load vs. interactive state | Separate loading state from interactive state |

## Pitfalls

1. **Don't split too granularly** — one sub-state per small button is over-engineering. Group semantically: all action buttons in one `ActionBarState`, all hero visuals in one `HeroState`.

2. **Full data loads must emit all sub-states at once** — don't emit `hero` in one `update {}` and `sourceList` in another. Each `update {}` triggers a recomposition check. Emitting all changed sub-states in one `update {}` is one recomposition; emitting in two `update {}` is two recompositions.

3. **Sub-states must be data classes** — Compose's snapshot system relies on `equals()` for state comparison. Lambdas in data classes use reference equality, so prefer value types in sub-states.

4. **Sub-states copy deeply** — `it.hero.copy(isInWatchlist = ...)` creates a new `HeroState` but shares the `genres` and `cacheBadge` references (which themselves haven't changed). This is fine — those are immutable data classes.
