Help us improve
Share bugs, ideas, or general feedback.
From performance-compose-skills
Jetpack Compose architecture patterns: Clean Architecture layering, MVVM/MVI with UiState/UiAction, Screen/Content split, collectAsStateWithLifecycle, stable UI models, and ViewModel scoping rules. Trigger: when structuring a Compose screen, wiring a ViewModel to UI, choosing MVVM vs MVI, handling UiState/UiAction patterns, or reviewing Compose architecture anti-patterns.
npx claudepluginhub santimattius/performance-compose-skills --plugin performance-compose-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/performance-compose-skills:compose-architectureThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.
Provides a checklist for code reviews covering functionality, security, performance, maintainability, tests, and quality. Use for pull requests, audits, team standards, and developer training.
Share bugs, ideas, or general feedback.
Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.
| Phase | What Runs | Restart Cost | Trigger |
|---|---|---|---|
| Composition | Composable functions, state reads | HIGH (whole scope) | State read in composable body |
| Layout | Measure + place | MEDIUM (subtree) | State read in measure lambda |
| Drawing | Canvas commands, graphicsLayer | LOW (single node) | State read in draw/graphicsLayer lambda |
Do you need the value during composition?
T (Composition phase) — accept full restart cost() -> T into Modifier.layout/offset { } lambda() -> T into Modifier.graphicsLayer { } / drawBehind { } lambdaRule: prefer Modifier.offset { lambda } over Modifier.offset(state.value.dp). Lambda variant defers the read to the layout phase, skipping recomposition entirely.
collectAsStateWithLifecycle vs collectAsState// CORRECT — lifecycle-aware + stable UiState → composables can skip
@Composable
fun CoursesScreen(viewModel: CoursesViewModel = hiltViewModel()) {
// collectAsStateWithLifecycle: stops collection when lifecycle is STOPPED
// (app backgrounded, screen off) — saves CPU and battery
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CoursesContent(state = uiState, onAction = viewModel::onAction)
}
// Stable UiState: all val properties + ImmutableList → composables are skippable
@Immutable
data class CoursesUiState(
val isLoading: Boolean = false,
val courses: ImmutableList<CourseUi> = persistentListOf(), // kotlinx.collections.immutable
val error: String? = null,
)
// WRONG — collectAsState collects forever AND mutable List makes composables non-skippable
@Composable
fun CoursesScreen(viewModel: CoursesViewModel = hiltViewModel()) {
// collectAsState: collects even when app is backgrounded → wasted CPU/battery
val uiState by viewModel.uiState.collectAsState()
CoursesContent(state = uiState, onAction = viewModel::onAction)
}
// Unstable UiState: var property → whole class inferred unstable → composables never skip
data class CoursesUiState(
var isLoading: Boolean = false, // var = UNSTABLE
val courses: List<CourseUi> = emptyList(), // mutable List = UNSTABLE
)
WARNING: NEVER profile in debug builds. Debug disables R8, inlining, and Strong Skipping — numbers are meaningless. Always profile a release build with
profileableenabled or a benchmark build type.
| Tool | Use For | When |
|---|---|---|
| Baseline Profiles | AOT-compile critical paths (startup, scroll) | Ship in release; regenerate per release |
| Compose Compiler Reports | Detect unstable params, restartable/skippable status | Every PR; fail CI on new unstable types |
| Layout Inspector (recomposition counts) | See which composables recompose and why | When debugging excess recomposition |
| Composition Tracing | Frame-level composition timing in Android Studio | When Layout Inspector is not enough |
| Macrobenchmark | Measure startup, frame timing, jank in release | Per-release regression gate |
Load this skill when any of the following applies:
collectAsStateWithLifecycle, stateIn, UiState shape)UiState or UiAction types (stability, sealed hierarchies)Canonical CMP rules:
../_shared/cmp-platform.md
| Source set | Status | Notes |
|---|---|---|
commonMain | ⚠️ | MVI/MVVM patterns ✅; Hilt ❌; ViewModel requires lifecycle-viewmodel-compose 2.10.0 |
androidMain | ✅ | Full skill content applies; Hilt available here |
iosMain | ⚠️ | ViewModel + StateFlow ✅ (lifecycle 2.10+); use Koin or expect/actual factory for DI |
desktopMain | ⚠️ | Same; also requires kotlinx-coroutines-swing in jvmMain |
wasmJsMain | ⚠️ | Same lifecycle version gate |
Status legend: ✅ fully supported · ⚠️ partial / version-gated · ❌ Android-only.
If using in CMP: Hilt is androidMain-only (kapt). For commonMain DI, use Koin (koin-compose-viewmodel) or a manual expect/actual factory. ViewModel and viewModelScope are multiplatform from org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose 2.10.0 — always provide an initializer: viewModel { MyViewModel() }. See references/cmp-architecture-boundary.md.
collectAsStateWithLifecycle — always, never collectAsStateFrom androidx.lifecycle:lifecycle-runtime-compose. Stops collecting when the lifecycle reaches STOPPED (app backgrounded, screen off), saving CPU and battery. collectAsState() collects forever regardless of lifecycle state.
This is a Strongly Recommended pattern in official Android docs — not optional.
// CORRECT
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// WRONG — collects forever
val uiState by viewModel.uiState.collectAsState()
In CMP: requires
androidx.lifecycle:lifecycle-runtime-compose≥ 2.8.0 incommonMain. Below 2.8, usecollectAsState()or aexpect/actualshim. See references/cmp-architecture-boundary.md.
stateIn canonical patternval uiState: StateFlow<CoursesUiState> = someFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 5 s timeout survives rotation
initialValue = CoursesUiState(),
)
The 5-second timeout keeps the pipeline alive through screen rotations without restarting. It stops when the UI is truly gone (not just rotated). NEVER use SharingStarted.Eagerly — the pipeline stays active even when no UI observes it, wasting CPU/battery.
AndroidViewModel — use ViewModel with constructor injection// CORRECT — framework-decoupled, testable
@HiltViewModel
class CoursesViewModel @Inject constructor(
private val loadCourses: LoadCoursesUseCase,
) : ViewModel() { ... }
// WRONG — couples ViewModel to Android framework, makes testing harder
class CoursesViewModel(application: Application) : AndroidViewModel(application) { ... }
In CMP:
ViewModelitself is multiplatform fromorg.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose2.10.0. Below 2.10, use theexpect/actualboundary in references/cmp-architecture-boundary.md.AndroidViewModelis Android-only and must never appear incommonMain.
Pass only the state and lambdas that the child needs. Child composables receiving a ViewModel cannot be previewed, tested independently, or reused. Screen-level composables and navigation destinations are the ONLY valid ViewModel access points.
// CORRECT
@Composable
fun CoursesScreen(viewModel: CoursesViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CoursesContent(state = uiState, onAction = viewModel::onAction)
}
@Composable
fun CoursesContent(state: CoursesUiState, onAction: (CoursesAction) -> Unit) { ... }
// WRONG — child receives ViewModel
@Composable
fun CourseCard(viewModel: CoursesViewModel) { ... } // cannot preview or test in isolation
Model navigation triggers, snackbars, and dialogs as Boolean or sealed class state fields in UiState, not as Channel or SharedFlow events. One-shot events are consumed and lost if the UI resubscribes; state is always replayable.
// CORRECT — navigation modelled as state
data class CoursesUiState(
val navigateToCourseId: String? = null, // null = no navigation pending
...
)
// WRONG — event lost if UI resubscribes before consuming
private val _navigationEvent = MutableSharedFlow<String>()
val navigationEvent: SharedFlow<String> = _navigationEvent
All properties MUST be val. Use ImmutableList<T> from kotlinx.collections.immutable or an @Immutable wrapper for list fields. Types with var properties are always inferred unstable — composables receiving them can never be skipped.
// CORRECT — fully stable
@Immutable
data class CourseUi(
val id: String,
val title: String,
val isBookmarked: Boolean,
)
@Immutable
data class CoursesUiState(
val courses: ImmutableList<CourseUi> = persistentListOf(),
val isLoading: Boolean = false,
)
// WRONG — var makes the whole class unstable
data class CoursesUiState(
var isLoading: Boolean = false, // var = unstable
val courses: List<CourseUi> = emptyList(), // mutable List = unstable
)
Hoist to the lowest common ancestor that needs to share state. But READ state as low in the tree as possible — pass () -> T lambdas to children for frequently-changing values so the read happens in the child's scope, not the parent's.
// CORRECT — pass lambda; scroll position read only in child scope
@Composable
fun Parent() {
val listState = rememberLazyListState()
Child(firstVisibleIndex = { listState.firstVisibleItemIndex })
}
@Composable
fun Child(firstVisibleIndex: () -> Int) {
val index = firstVisibleIndex() // read deferred to this scope
}
// WRONG — parent reads and passes the value → parent recomposes on every scroll event
@Composable
fun Parent() {
val listState = rememberLazyListState()
Child(firstVisibleIndex = listState.firstVisibleItemIndex) // read HERE = parent recomposes
}
@Stable for plain class state holdersFor reusable UI component state holders (not ViewModels), use a plain class annotated @Stable with MutableState properties. Do NOT use ViewModel for per-component state holders — they live too long and carry DI overhead.
@Stable
class MapState {
private val _location = mutableStateOf<Position?>(null)
val latitude by derivedStateOf { _location.value?.latitude }
val longitude by derivedStateOf { _location.value?.longitude }
fun changeLocation(position: Position) {
_location.value = position
}
}
@Composable
fun rememberMapState(): MapState = remember { MapState() }
One Screen composable accesses state and handles navigation callbacks (connects to ViewModel); one Content composable renders pure UI given immutable state. The Content composable is stateless, previewable, and testable in isolation.
// SCREEN — connects to ViewModel, handles navigation
@Composable
fun CoursesScreen(
viewModel: CoursesViewModel = hiltViewModel(),
onOpenCourse: (String) -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CoursesContent(
state = uiState,
onAction = viewModel::onAction,
onCourseClick = onOpenCourse,
modifier = Modifier.fillMaxSize(),
)
}
// CONTENT — pure, previewable, testable
@Composable
fun CoursesContent(
state: CoursesUiState,
onAction: (CoursesAction) -> Unit,
onCourseClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
// Only renders — no ViewModel access
}
_uiState.update { it.copy(...) } for atomic updatesPrevents intermediate inconsistent states under concurrent coroutines.
// CORRECT — atomic under concurrency
_uiState.update { it.copy(isLoading = false, courses = newCourses) }
// WRONG — race condition possible
_uiState.value = _uiState.value.copy(isLoading = false, courses = newCourses)
| Pitfall | Fix | Phase Cost |
|---|---|---|
collectAsState() | collectAsStateWithLifecycle() | Collects forever vs. stops on STOPPED lifecycle |
SharingStarted.Eagerly in stateIn | WhileSubscribed(5_000) | Pipeline active with no UI → wasted CPU/battery |
List<T> in UiState | ImmutableList<T> or @Immutable wrapper | Composable always non-skippable (unstable type) |
var properties in UiState data class | All val — use MutableState for reactive fields | Entire class inferred unstable |
| ViewModel instance passed to child composable | Pass state: UiState + onAction: (Action) -> Unit | Non-previewable, non-testable child |
AndroidViewModel | ViewModel + @HiltViewModel constructor injection | Framework coupling, test friction |
| Reading list state in parent body | Pass () -> List<T> lambda or read in child scope | Full Composition on every list change |
One-shot event via Channel/SharedFlow | Model as state field in UiState | Event lost on resubscription |
_uiState.value = _uiState.value.copy(x = y) under concurrency | _uiState.update { it.copy(x = y) } | Race condition → intermediate inconsistent state |
@HiltViewModel
class CoursesViewModel @Inject constructor(
private val loadCourses: LoadCoursesUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(CoursesUiState())
val uiState: StateFlow<CoursesUiState> = _uiState
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CoursesUiState())
fun onAction(action: CoursesAction) {
when (action) {
is CoursesAction.BookmarkClicked -> toggleBookmark(action.id)
CoursesAction.RetryClicked -> loadCourses()
}
}
private fun toggleBookmark(courseId: String) {
_uiState.update { state ->
state.copy(
courses = state.courses.map { course ->
if (course.id == courseId) course.copy(isBookmarked = !course.isBookmarked)
else course
}.toPersistentList()
)
}
}
}
@Immutable
data class CoursesState(
val isLoading: Boolean = false,
val courses: ImmutableList<CourseUi> = persistentListOf(),
val error: String? = null,
)
sealed interface CoursesIntent {
data object Load : CoursesIntent
data class BookmarkClicked(val id: String) : CoursesIntent
}
sealed interface CoursesChange {
data object Loading : CoursesChange
data class Data(val courses: List<CourseUi>) : CoursesChange
data class Failure(val message: String) : CoursesChange
}
private fun CoursesState.reduce(change: CoursesChange): CoursesState = when (change) {
CoursesChange.Loading -> copy(isLoading = true, error = null)
is CoursesChange.Data -> copy(isLoading = false, courses = change.courses.toPersistentList(), error = null)
is CoursesChange.Failure -> copy(isLoading = false, error = change.message)
}
// Sealed hierarchy allows passing subtype lambda to child — no extra wrappers needed
sealed class CoursesAction {
sealed class Header : CoursesAction() {
data object Refresh : Header()
}
sealed class Item : CoursesAction() {
data class BookmarkClicked(val id: String) : Item()
data class CourseClicked(val id: String) : Item()
}
}
// CourseCard receives (CoursesAction.Item) -> Unit — a subtype of (CoursesAction) -> Unit
@Composable
fun CourseCard(
model: CourseUi,
onAction: (CoursesAction.Item) -> Unit,
modifier: Modifier = Modifier,
) { ... }
// Each composable section receives only its slice — no over-passing
@Immutable
data class UserProfileUiState(
val header: Header,
val details: Details,
) {
@Immutable
data class Header(val fullName: String, val avatarUrl: String?)
@Immutable
data class Details(val followersCount: Int, val bio: String)
}
No CLI commands are specific to this skill. Relevant Gradle dependencies:
// Lifecycle-aware state collection
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.+")
// Stable collections for UiState
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
// Hilt ViewModel injection (androidMain only — Android-only)
implementation("com.google.dagger:hilt-android:2.51.+")
kapt("com.google.dagger:hilt-compiler:2.51.+")
implementation("androidx.hilt:hilt-navigation-compose:1.2.+")
// CMP: Koin alternative for commonMain DI
// implementation("io.insert-koin:koin-compose-viewmodel:3.5+")
// implementation("io.insert-koin:koin-core:3.5+")
// CMP: multiplatform ViewModel + lifecycle (lifecycle-viewmodel-compose 2.10.0)
// implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
| Skill | Path | What It Adds |
|---|---|---|
compose-composition-core | ../compose-composition-core/SKILL.md | Stability, recomposition, and state fundamentals that UiState design depends on |
compose-effects | ../compose-effects/SKILL.md | LaunchedEffect patterns used in Screen composables for one-time events |
compose-navigation-nav3 | ../compose-navigation-nav3/SKILL.md | How ViewModel scoping works per-destination in Nav3 |
| File | Covers |
|---|---|
| references/README.md | Index of all reference files |
| references/clean-architecture-layers.md | CA layers, UDF, Screen/Content split |
| references/mvvm-patterns.md | ViewModel, UiState, collectAsStateWithLifecycle |
| references/conventions.md | Naming, Component/Factory/Effect types |
collectAsStateWithLifecycle, stateIn, WhileSubscribed, @HiltViewModel, @Stable, @Immutable, ImmutableList (kotlinx.collections.immutable)AndroidViewModel (official docs 2026-05), collectAsState() (use lifecycle-aware variant), SharingStarted.Eagerly