/compose-state-holder-ui-split
Use when a Jetpack Compose screen-level composable takes a ViewModel/component/controller, collects state or effects, handles navigation/snackbars, or wires callbacks while also rendering layout. Technique-layer skill — complements the codebase-specific compose-expert and
$ npx -y skills add vitorpamplona/amethyst --skill compose-state-holder-ui-split --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/compose-state-holder-ui-split
Context preview
The summary Claude sees to decide when to auto-load this skill.
Use when a Jetpack Compose screen-level composable takes a ViewModel/component/controller, collects state or effects, handles navigation/snackbars, or wires callbacks while also rendering layout. Technique-layer skill — complements the codebase-specific compose-expert and
SKILL.md
compose-state-holder-ui-split.SKILL.mdname: compose-state-holder-ui-split
description: Use when a Jetpack Compose screen-level composable takes a ViewModel/component/controller, collects state or effects, handles navigation/snackbars, or wires callbacks while also rendering layout. Technique-layer skill — complements the codebase-specific compose-expert and feed-patterns.
Compose: state holder/UI split
Core principle
Separate state-holder wiring from UI rendering. The state-holder composable talks to ViewModels, components, flows, navigation, and side effects. The UI composable takes plain immutable UI state plus callbacks and describes layout.
This keeps screens previewable, testable, and easier to reuse across Android, Desktop, TV, and KMP/CMP targets.
When to use this skill
Use this when a Compose screen:
- Takes a ViewModel, component, controller, navigator, repository, or service directly.
- Collects app/business state or side effects in the same function that lays out most UI.
- Passes a whole state holder into child composables instead of explicit state and callbacks.
- Is hard to preview because it needs dependency injection, navigation, lifecycle, or fake services.
- Has UI tests that must construct a full app stack to verify a simple layout branch.
The pattern
Use a small public state-holder composable:
@Composable
fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) {
val state by component.state.collectAsStateWithLifecycle()
ProfileScreen(
state = state,
onNameChange = component::onNameChange,
onSaveClick = component::save,
onBackClick = component::back,
modifier = modifier,
)
}Then put UI in a plain composable that knows nothing about the state holder:
@Composable
fun ProfileScreen(
state: ProfileUiState,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ProfileContent(
name = state.name,
isSaving = state.isSaving,
canSave = state.canSave,
onNameChange = onNameChange,
onSaveClick = onSaveClick,
onBackClick = onBackClick,
modifier = modifier,
)
}Private content functions can break up layout:
@Composable
private fun ProfileContent(
name: String,
isSaving: Boolean,
canSave: Boolean,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Layout only.
}Rules of thumb
| Concern | State-holder composable | UI composable | |---|---|---| | Collect ViewModel/component state | Yes | No | | Collect one-shot effects | Yes, or a tiny sibling effect handler | Usually no | | Hold dependency-injected objects | Yes | No | | Accept immutable UI state | Usually passes it through | Yes | | Accept lambdas for user events | Wires them | Calls them | | Own layout, modifiers, semantics, test tags | No/minimal | Yes | | Own UI-local state like scroll, focus, text input, animation, interaction | Sometimes seeds it | Yes | | Preview/screenshot friendly | Not necessarily | Yes |
The "no collection in UI composables" rule is about app/business state and side-effect streams. Plain UI composables can still own UI-local framework state: `rememberScrollState`, `rememberLazyListState`, `FocusRequester`, focus state, animation state, `TextFieldState`, `MutableInteractionSource.collectIsPressedAsState()`, and similar behavior that belongs to the rendered widget.
If that UI-local state grows into coordinated behavior with multiple related fields and operations, consult `compose-expert` (state hoisting section) to decide whether it should become a plain state holder class remembered in composition.
What to pass
Pass the smallest useful UI contract:
- Prefer a dedicated `UiState`/`State` object over many unrelated primitives when the screen has real state.
- Prefer explicit lambdas (`onRetryClick`, `onItemSelected`) over passing a whole component.
- Keep domain models out of the UI composable if they force business rules into UI. Map to UI models when the UI needs a different shape.
- Keep navigation as callbacks. The UI composable says "user clicked back", not "navigate to route X".
- Frame-rate or UI-local values that should not force whole-tree recomposition when they change: prefer provider lambdas and deferred reads per [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md).
Side effects
[`compose-side-effects`](../compose-side-effects/SKILL.md) covers effect APIs (`LaunchedEffect`, `DisposableEffect`, `SideEffect`), keys, cleanup, and `rememberUpdatedState`.
Handle effects near the state holder, where the effect source and imperative target are both available:
@Composable
fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) {
val state by component.state.collectAsStateWithLifecycle()
LaunchedEffect(component) {
component.effects.collect { effect ->
when (effect) {
ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved")
}
}
}
ProfileScreen(state = state, onSaveClick = component::save)
}If effect handling grows, extract `ProfileEffects(component, snackbarHostState)` rather than pushing the component into the UI composable.
Common mistakes
| Mistake | Why it hurts | Fix | |---|---|---| | `fun Screen(viewModel: MyViewModel)` contains all layout | Hard to preview/test without Android lifecycle and DI | Add a plain UI overload that takes `state` and callbacks | | Child composables take `component` | Dependencies leak through the tree | Pass only the state/callbacks that child needs | | UI composable launches navigation | UI becomes coupled to app routing | Expose `onBackClick`, `onItemClick`, etc. | | UI composable collects app/business flows | Collection lifecycle is hidden in layout
Read more
name: compose-state-holder-ui-split description: Use when a Jetpack Compose screen-level composable takes a ViewModel/component/controller, collects state or effects, handles navigation/snackbars, or wires callbacks while also rendering layout. Technique-layer skill — complements the codebase-specific compose-expert and feed-patterns.
Compose: state holder/UI split
Core principle
Separate state-holder wiring from UI rendering. The state-holder composable talks to ViewModels, components, flows, navigation, and side effects. The UI composable takes plain immutable UI state plus callbacks and describes layout.
This keeps screens previewable, testable, and easier to reuse across Android, Desktop, TV, and KMP/CMP targets.
When to use this skill
Use this when a Compose screen:
- Takes a ViewModel, component, controller, navigator, repository, or service directly.
- Collects app/business state or side effects in the same function that lays out most UI.
- Passes a whole state holder into child composables instead of explicit state and callbacks.
- Is hard to preview because it needs dependency injection, navigation, lifecycle, or fake services.
- Has UI tests that must construct a full app stack to verify a simple layout branch.
The pattern
Use a small public state-holder composable:
@Composable
fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) {
val state by component.state.collectAsStateWithLifecycle()
ProfileScreen(
state = state,
onNameChange = component::onNameChange,
onSaveClick = component::save,
onBackClick = component::back,
modifier = modifier,
)
}Then put UI in a plain composable that knows nothing about the state holder:
@Composable
fun ProfileScreen(
state: ProfileUiState,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ProfileContent(
name = state.name,
isSaving = state.isSaving,
canSave = state.canSave,
onNameChange = onNameChange,
onSaveClick = onSaveClick,
onBackClick = onBackClick,
modifier = modifier,
)
}Private content functions can break up layout:
@Composable
private fun ProfileContent(
name: String,
isSaving: Boolean,
canSave: Boolean,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Layout only.
}Rules of thumb
| Concern | State-holder composable | UI composable | |---|---|---| | Collect ViewModel/component state | Yes | No | | Collect one-shot effects | Yes, or a tiny sibling effect handler | Usually no | | Hold dependency-injected objects | Yes | No | | Accept immutable UI state | Usually passes it through | Yes | | Accept lambdas for user events | Wires them | Calls them | | Own layout, modifiers, semantics, test tags | No/minimal | Yes | | Own UI-local state like scroll, focus, text input, animation, interaction | Sometimes seeds it | Yes | | Preview/screenshot friendly | Not necessarily | Yes |
The "no collection in UI composables" rule is about app/business state and side-effect streams. Plain UI composables can still own UI-local framework state: `rememberScrollState`, `rememberLazyListState`, `FocusRequester`, focus state, animation state, `TextFieldState`, `MutableInteractionSource.collectIsPressedAsState()`, and similar behavior that belongs to the rendered widget.
If that UI-local state grows into coordinated behavior with multiple related fields and operations, consult `compose-expert` (state hoisting section) to decide whether it should become a plain state holder class remembered in composition.
What to pass
Pass the smallest useful UI contract:
- Prefer a dedicated `UiState`/`State` object over many unrelated primitives when the screen has real state.
- Prefer explicit lambdas (`onRetryClick`, `onItemSelected`) over passing a whole component.
- Keep domain models out of the UI composable if they force business rules into UI. Map to UI models when the UI needs a different shape.
- Keep navigation as callbacks. The UI composable says "user clicked back", not "navigate to route X".
- Frame-rate or UI-local values that should not force whole-tree recomposition when they change: prefer provider lambdas and deferred reads per [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md).
Side effects
[`compose-side-effects`](../compose-side-effects/SKILL.md) covers effect APIs (`LaunchedEffect`, `DisposableEffect`, `SideEffect`), keys, cleanup, and `rememberUpdatedState`.
Handle effects near the state holder, where the effect source and imperative target are both available:
@Composable
fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) {
val state by component.state.collectAsStateWithLifecycle()
LaunchedEffect(component) {
component.effects.collect { effect ->
when (effect) {
ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved")
}
}
}
ProfileScreen(state = state, onSaveClick = component::save)
}If effect handling grows, extract `ProfileEffects(component, snackbarHostState)` rather than pushing the component into the UI composable.
Common mistakes
| Mistake | Why it hurts | Fix | |---|---|---| | `fun Screen(viewModel: MyViewModel)` contains all layout | Hard to preview/test without Android lifecycle and DI | Add a plain UI overload that takes `state` and callbacks | | Child composables take `component` | Dependencies leak through the tree | Pass only the state/callbacks that child needs | | UI composable launches navigation | UI becomes coupled to app routing | Expose `onBackClick`, `onItemClick`, etc. | | UI composable collects app/business flows | Collection lifecycle is hidden in layout
Other skills on amethyst.
- /account-state
Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by
Open skill - /amy-expert
Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/`
Open skill - /android-expert
Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4)
Open skill - /auth-signers
Signer abstraction patterns in Amethyst. Use when working with event signing, choosing between a local keypair (`NostrSignerInternal`), a remote NIP-46 bunker signer (`NostrSignerRemote`), or a NIP-55 Android external-app signer (`NostrSignerExternal`). Covers the abstract
Open skill - /compose-expert
Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector
Open skill - /compose-modifier-and-layout-style
Use when writing or reviewing Jetpack Compose layout APIs, modifier parameters, modifier chain construction, hardcoded root layout decisions, or layout wrappers around a single conditional. Technique-layer skill — complements the codebase-specific compose-expert.
Open skill

