kmp-compose-architecture
Skill18 starsChanged 4 months agoMITView on GitHub
Compose Multiplatform screen-level architecture for Kotlin Multiplatform (Android + iOS) - stability, recomposition strategy, state ownership, side effects, lazy layouts, deferred state reads, and TextField state dispatching. Use this skill whenever architecting a Compose screen's structure, optimizing recomposition at the screen level, deciding state ownership between ViewModel and composables, adding side effects, structuring lazy layouts, or making screen-level performance decisions. This skill is about HOW screens are structured, not how individual components are built — for component construction, see kmp-compose-components. Trigger on phrases like "recomposition", "stability", "@Stable", "LaunchedEffect", "DisposableEffect", "LazyColumn performance", "collectAsStateWithLifecycle", "derivedStateOf", "screen layout", "Compose performance", "deferred state read", or "state ownership".
In this repository67 other files it gives its agents.
- agent-onboardingSkill
- android-backgroundSkill
- android-cliSkill
- android-compose-architectureSkill
- android-compose-componentsSkill
- android-data-layerSkill
- android-di-koinSkill
- android-error-handlingSkill
- android-integration-testingSkill
- android-jacoco-setupSkill
- android-jvm-test-coverageSkill
- android-module-structureSkill
- android-navigationSkill
- android-presentation-mviSkill
- android-preview-figma-verifySkill
- android-ui-screenshot-verifySkill
- android-ui-testingSkill
- android-ui-verifySkill
- android-unit-testingSkill
- android-version-catalogSkill
- architecture-decision-recordSkill
- careful-planningSkill
- compose-adaptive-layoutsSkill
- figma-to-android-vectorSkill
- finalizeSkill
- git-commitSkill
- handoffSkill
- kmp-compose-componentsSkill
- kmp-data-layerSkill
- kmp-di-koinSkill
- kmp-error-handlingSkill
- kmp-integration-testingSkill
- kmp-migration-analyzerSkill
- kmp-module-structureSkill
- kmp-navigationSkill
- kmp-presentation-mviSkill
- kmp-ui-testingSkill
- kmp-unit-testingSkill
- kotlin-coroutinesSkill
- kotlin-flowsSkill
- kotlin-mutation-testingSkill
- openspec-artifact-review-lightSkill
- openspec-artifact-reviewSkill
- project-breakdownSkill
- react-data-layerSkill
- react-error-handlingSkill
- react-integration-testingSkill
- react-presentation-mviSkill
- react-project-structureSkill
- react-routingSkill
- react-screen-architectureSkill
- react-securitySkill
- react-server-stateSkill
- react-ui-componentsSkill
- react-unit-testingSkill
- speckit-parallelize-agent-teamsSkill
- speckit-parallelize-subagentsSkill
- spring-api-layerSkill
- spring-di-configurationSkill
- spring-integration-testingSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 3 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 9
All screens are written with Compose Multiplatform in commonMain and run on Android and iOS.
- Very specific: line 21
Only annotate a state data class with @Stable when it contains fields the Compose compiler considers unstable (e.g., List, Map, Set, …
- Very specific: line 43
All state lives in the ViewModel.
- Distinctive: line 59
Always collect ViewModel state with lifecycle awareness:
- Very specific: line 74
When a side effect is truly necessary (e.g., interacting with Lifecycle APIs that have no ViewModel equivalent), extract it into a …
- Very specific: line 105
Add key to lazy list items when there is an obvious unique identifier available.
- Distinctive: line 122
Avoid animations that cause recompositions.
- Distinctive: line 142
Deferred state reads: When a value drives an animation, pass it as a lambda rather than reading it directly.
- Very specific: line 174
Every Screen composable should have at least one meaningful @Preview that shows a realistic state.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
KMP Compose Screen Architecture
All screens are written with Compose Multiplatform in commonMain and run on Android and iOS. Imports stay androidx.compose.* / androidx.lifecycle.*; the artifacts are the JetBrains ones (org.jetbrains.compose.*, org.jetbrains.androidx.lifecycle:*) — see kmp-module-structure.
Core Principle
The UI is dumb. Composables render state and forward user actions — nothing more. All state lives in the ViewModel. All logic lives in the ViewModel, domain, or data layer. Compose code should contain zero business logic, zero data transformation, and minimal side effects.
Stability & Recomposition
Strong skipping mode is enabled by default in modern Compose — no explicit opt-in needed.
Only annotate a state data class with @Stable when it contains fields the Compose compiler considers unstable (e.g., List, Map, Set, interfaces, or abstract types). If all fields are primitive types, String, or other stable types, no annotation is needed.
// Needs @Stable — contains a List (unstable by default)
@Stable
data class NoteListState(
val notes: List<NoteUi> = emptyList(),
val isLoading: Boolean = false
)
// No annotation needed — all fields are stable
data class NoteDetailState(
val title: String = "",
val body: String = "",
val isSaving: Boolean = false
)State Ownership
All state lives in the ViewModel. Do not use remember or rememberSaveable for application state — that belongs in the ViewModel's StateFlow and is surfaced via collectAsStateWithLifecycle().
The only exception is Compose-internal state that the framework requires you to hold in composition, such as LazyListState, ScrollState, or PagerState. For these, use remember* as needed:
// Acceptable — Compose-owned UI state
val lazyListState = rememberLazyListState()
// Reacting to Compose-owned state with derivedStateOf
val showScrollToTop by remember {
derivedStateOf { lazyListState.firstVisibleItemIndex > 5 }
}derivedStateOf should only be used in these rare scenarios where Compose-internal state drives a derived value. If the derivation can happen in the ViewModel, it should.
Always collect ViewModel state with lifecycle awareness:
// import androidx.lifecycle.compose.collectAsStateWithLifecycle
// artifact: org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose
val state by viewModel.state.collectAsStateWithLifecycle()This is lifecycle-aware on iOS as well: Compose Multiplatform maps the iOS app lifecycle onto Lifecycle (ON_STOP when the app goes to the background, ON_START when it returns).
Side Effects
Side effects should be avoided when possible. If something can be handled by the ViewModel through an Action, do that instead of using a side effect in a composable.
When a side effect is truly necessary (e.g., interacting with Lifecycle APIs that have no ViewModel equivalent), extract it into a dedicated composable to keep the Screen composable clean:
// Extracted into its own composable
@Composable
fun ObserveLifecycle(onStart: () -> Unit, onStop: () -> Unit) {
// import androidx.lifecycle.compose.LocalLifecycleOwner (multiplatform),
// NOT androidx.compose.ui.platform.LocalLifecycleOwner
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> onStart()
Lifecycle.Event.ON_STOP -> onStop()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}LaunchedEffect is acceptable when genuinely needed, but question whether the work belongs in the ViewModel first. Do not use custom CompositionLocals.
Side effects that need a platform API (permissions, share sheet, haptics, opening a URL) never branch on the platform inside a Screen composable. Put them behind an interface provided via Koin or an expect/actual function. LocalContext, LocalConfiguration, and androidx.activity.compose.* (BackHandler, rememberLauncherForActivityResult) do not exist in commonMain.
Lazy Layouts
Add key to lazy list items when there is an obvious unique identifier available. Don't force it if it's unclear which property is unique:
LazyColumn {
items(
items = state.notes,
key = { it.id } // id is clearly unique
) { note ->
NoteItem(note = note, onClick = { onAction(OnNoteClick(note.id)) })
}
}Animations — Recomposition Avoidance
Avoid animations that cause recompositions. Prefer approaches that animate below the recomposition layer:
graphicsLayer— for alpha, scale, rotation, translation- Offset lambda — for position changes (
offset { ... }) Canvas— for custom drawing that animatesanimateFloatAsState+graphicsLayer— animate a float, apply in graphicsLayer
// Good — animates without recomposition
val alpha by animateFloatAsState(if (state.isVisible) 1f else 0f)
Box(
modifier = Modifier.graphicsLayer { this.alpha = alpha }
)
// Bad — causes recomposition on every frame
Box(
modifier = Modifier.alpha(animatedAlpha)
)Deferred state reads: When a value drives an animation, pass it as a lambda rather than reading it directly. This defers the state read to the layout/draw phase and avoids recomposition:
// Good — deferred read
fun Modifier.animatedOffset(offsetProvider: () -> IntOffset) =
offset { offsetProvider() }
// Bad — immediate read causes recomposition
fun Modifier.animatedOffset(offset: IntOffset) =
offset(x = offset.x.dp, y = offset.y.dp)TextField
Text input state lives in the ViewModel. Every keystroke dispatches an Action:
// In the Screen composable
TextField(
value = state.title,
onValueChange = { onAction(NoteEditorAction.OnTitleChange(it)) }
)The ViewModel updates state (and optionally persists to SavedStateHandle) in response to the Action — see the kmp-presentation-mvi skill for the full pattern.
Screen Previews
Every Screen composable should have at least one meaningful @Preview that shows a realistic state. Previews live in commonMain next to the screen and are rendered by Android Studio through the Android target:
@Preview
@Composable
private fun NoteListScreenPreview() {
AppTheme {
NoteListScreen(
state = NoteListState(
notes = listOf(
NoteUi("1", "Meeting notes", "Mar 15"),
NoteUi("2", "Shopping list", "Mar 14")
)
),
onAction = {}
)
}
}Wrap previews in the app theme so they reflect real appearance. Use realistic sample data, not empty states (unless previewing the empty state specifically).
For component-level preview conventions (one preview per meaningful state, separate functions), see kmp-compose-components.
For component construction rules (IconButton wrapping, sizing, design system reuse, icons, string resources, previews per state), see kmp-compose-components.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

