jetpack-compose-builder
Skill0 starsChanged 39 days agoMITView on GitHub
Builds Android Jetpack Compose UI with hoisted state, unidirectional data flow, and recomposition-safe patterns backed by measured stability rules. Use when someone asks "build this screen in Compose", "where should this state live", "why does my LazyColumn stutter", or is writing Composable functions, ViewModel and StateFlow wiring, or fixing recomposition jank in Kotlin. Do NOT use when the screen targets Flutter - use flutter-widget-architect instead; for iOS declarative UI use swift-ui; for React Native screens use react-native-pro; and if jank persists after state fixes, profile with mobile-perf-profiler.
In this repository7 other files it gives its agents.
- app-store-release-prepSkill
- flutter-widget-architectSkill
- mobile-offline-syncSkill
- mobile-perf-profilerSkill
- push-notification-wirerSkill
- react-native-proSkill
- swift-uiSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 10 are distinctive, and 0 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈10
Distinctive · estimate
This author's own choices. Worth a look.
0
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 15
Before writing a composable, establish:
- Distinctive: line 55
Read state low. A State read subscribes the reading scope.
- Distinctive: line 112
Every leaf composable is stateless, parameterized, and renders in a @Preview without a ViewModel.
- Distinctive: line 121
Do NOT keep mutable UI state inside a child composable the parent must drive - hoist it.
- Distinctive: line 122
Do NOT launch coroutines or mutate state directly in the composable body.
- Distinctive: line 123
Do NOT key LaunchedEffect/remember on Unit when it should re-run on an id or input change.
- Distinctive: line 124
Do NOT nest a LazyColumn/LazyRow inside a verticalScroll/horizontalScroll of the same axis.
- Distinctive: line 125
Do NOT pass unstable params (interface collections, var-property classes, per-pass lambdas) into measured-hot composables - they defeat …
- Distinctive: line 126
Do NOT scatter @Stable/@Immutable annotations or premature optimizations before measuring.
- Distinctive: line 127
Do NOT rewrite working XML/View screens to chase purity.
≈ “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
Jetpack Compose Builder
Build the state and data flow first, the pixels second - most Compose bugs are state-ownership and recomposition bugs, not layout bugs. The costly failure is a screen that works in a demo, then recomposes entire subtrees on every keystroke or scroll frame because state lives in the wrong place and parameters are unstable.
Operating procedure
Step 1: Gather inputs
Before writing a composable, establish:
- The screen's state shape: what the user sees, what can change, and what triggers each change.
- Which state is UI-local (text field focus, expanded flags) versus screen-level (loaded data, selection) versus app-level (auth, theme).
- The data source: ViewModel exposing StateFlow, or a snapshot-state holder for pure-UI screens.
- List characteristics if any: item count, item identity (stable server ids?), reorder/insert behavior.
- Refresh-rate target of the flagship devices in the install base (60Hz gives a 16.6ms frame budget, 120Hz gives 8.3ms).
Step 2: Map state ownership before drawing anything
Decide which state is UI-local (remember) versus screen-level (ViewModel exposing StateFlow, collected via collectAsStateWithLifecycle()). Hold mutable state in the lowest common ancestor that needs it - no lower (parent cannot drive it) and no higher (recomposes too wide a scope).
Step 3: Make composables stateless by default
Pass immutable state down as parameters; emit changes up via lambdas (onValueChange, onClick). A composable that owns its own remember { mutableStateOf } cannot be controlled, previewed, or tested by its parent - hoist it. Every leaf composable must render in a @Preview without a ViewModel.
Step 4: Remember with correct keys
Use remember to cache allocations and computations across recompositions, rememberSaveable to survive config change and process death. Key remember, LaunchedEffect, and derivedStateOf on the inputs they depend on; LaunchedEffect(Unit) where an id should restart the effect is a bug. Wrap derived values whose input changes faster than the output (scroll offset to "show FAB" boolean) in derivedStateOf so downstream recomposition happens only when the result changes.
Step 5: Confine side effects to effect handlers
LaunchedEffect for scoped suspend work, rememberCoroutineScope for event-driven launches, DisposableEffect for cleanup, SideEffect to publish Compose state to non-Compose code. Never launch coroutines or write state in the composable body - state writes during composition throw or loop.
Step 6: Build lists with Lazy layouts and stable keys
Use LazyColumn/LazyRow with a stable item key (server id, not index) so reorders animate and item state survives. Hoist scroll position with rememberLazyListState when it must be observed or controlled. Prefer slot APIs (content lambdas) over boolean flags. Never nest a lazy list inside a scrollable of the same axis.
Step 7: Contain recomposition with the cost rules
Apply the recomposition cost rules below, cheapest first: push state reads down, defer reads past composition, then fix stability.
Step 8: Measure before optimizing
Capture recomposition counts in Layout Inspector and skippability in the Compose compiler metrics report (enable the compiler's reports output and read the -classes.txt for "unstable" parameters). Fix the composable that actually recomposes too often. Add stability annotations only against that evidence. If frames still miss budget after recomposition is contained, move to mobile-perf-profiler for a full trace.
Recomposition cost rules
Compose skips a composable only when the call site's arguments are all stable and unchanged. Work through these in order:
- Read state low. A State read subscribes the reading scope. Push reads into the smallest composable that needs the value; passing State down and reading it in the leaf beats reading it at the screen root.
- Defer reads past composition. Composition costs more than layout, which costs more than draw. Lambda-based modifiers (Modifier.offset { } instead of Modifier.offset(), Modifier.drawBehind) move a read out of composition entirely - a scroll-driven offset should never recompose anything.
- Fix parameter stability. Unstable and skip-defeating: classes with var properties, List/Map/Set interface types (compiler cannot prove immutability - use kotlinx.collections.immutable or wrap in an @Immutable holder), and types from modules without the Compose compiler. Annotate @Immutable/@Stable only when the contract truly holds.
- Stabilize lambdas. A lambda capturing an unstable receiver is recreated each pass and defeats skipping in hot paths; use method references or remember the lambda keyed on its captures. Do this only for measured-hot composables - everywhere is noise.
- Budget list items. A LazyColumn row's composition should be well under 1ms on a mid-tier device; a row that allocates, parses, or formats dates inline will blow the 16.6ms frame during fling. Precompute in the ViewModel, pass display-ready strings.
Red line: if Layout Inspector shows a composable recomposing on interactions that do not concern it (typing in an unrelated field recomposes the whole list), that is the bug to fix - not a candidate for micro-tuning.
Worked artifact: bad vs good list row
Bad - unstable params, inline work, index keys:
@Composable
fun MessageList(vm: ChatViewModel) { // child reaches into ViewModel
val messages by vm.messages.collectAsState()
LazyColumn {
items(messages.size) { i -> // index key: reorders reset state
val m = messages[i]
Row {
Text(SimpleDateFormat("HH:mm").format(m.sentAt)) // allocation + parse per recomposition
Text(m.body)
Button(onClick = { vm.retry(m) }) { Text("Retry") } // lambda captures vm per pass
}
}
}
}Good - stateless, stable keys, display-ready data, hoisted events:
@Immutable
data class MessageUi(val id: String, val timeLabel: String, val body: String)
@Composable
fun MessageList(
messages: ImmutableList<MessageUi>,
onRetry: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier) {
items(messages, key = { it.id }) { m ->
MessageRow(message = m, onRetry = onRetry) // previews without a ViewModel
}
}
}Time formatting moved to the ViewModel, identity keys keep row state across reorders, and every parameter is stable so unchanged rows skip.
Deliverable
Produce the screen as a state-hoisted composable tree: an immutable UI-state class, a stateless screen composable taking state plus event lambdas, preview functions for the key states (loading, content, error), and - for any performance claim - the Layout Inspector recomposition counts or compiler-metrics lines that justify it.
Quality bar
- Every leaf composable is stateless, parameterized, and renders in a @Preview without a ViewModel.
- State flows down as immutable values; events flow up as lambdas. No two-way ownership.
- Effects are keyed on their real inputs; no LaunchedEffect(Unit) that should restart on an id.
- Lists carry stable identity keys; no scrolling list nested in a same-axis scrollable.
- Hot-path parameters are stable per the compiler metrics report; frames hold 16.6ms (60Hz) or 8.3ms (120Hz) during scroll on a mid-tier device.
- Any optimization is backed by a measured recomposition count, not a guess.
Do NOT
- Do NOT keep mutable UI state inside a child composable the parent must drive - hoist it.
- Do NOT launch coroutines or mutate state directly in the composable body.
- Do NOT key LaunchedEffect/remember on Unit when it should re-run on an id or input change.
- Do NOT nest a LazyColumn/LazyRow inside a verticalScroll/horizontalScroll of the same axis.
- Do NOT pass unstable params (interface collections, var-property classes, per-pass lambdas) into measured-hot composables - they defeat skipping.
- Do NOT scatter @Stable/@Immutable annotations or premature optimizations before measuring; a wrong stability promise causes stale-UI bugs.
- Do NOT rewrite working XML/View screens to chase purity; AndroidView is fine for heavy interop or established View code.
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.

