Help us improve
Share bugs, ideas, or general feedback.
From chrisbanes-skills
Separates state-holder composable from UI composable in Jetpack Compose to improve previewability, testability, and reusability across Android, Desktop, and KMP targets.
npx claudepluginhub chrisbanes/skills --plugin chrisbanes-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/chrisbanes-skills:compose-state-holder-ui-splitThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
Guides state hoisting decisions in Jetpack Compose: local remember, hoisted parameters, plain state holder, or ViewModel.
Guides building native Android UIs with Jetpack Compose, including state management via remember/mutableStateOf, state hoisting, and ViewModel integration.
Guides building Android UIs with Jetpack Compose: project setup, MVVM state management with ViewModels/StateFlow, navigation, performance optimization, Material 3.
Share bugs, ideas, or general feedback.
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.
Use this when a Compose screen:
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.
}
| 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, use compose-state-hoisting to decide whether it should become a plain state holder class remembered in composition.
Pass the smallest useful UI contract:
UiState/State object over many unrelated primitives when the screen has real state.onRetryClick, onItemSelected) over passing a whole component.compose-state-deferred-reads.compose-side-effects 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.
| 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 | Collect near the state holder and pass values down |
| UI-local state is hoisted into the state holder for no reason | State holder starts owning layout mechanics | Keep scroll/focus/animation/text-field interaction state in the UI composable when it is only UI behavior |
| Every tiny composable gets a state-holder overload | Too much ceremony | Split at screen/section boundaries, not every Row |
Button, Card, or ListItem; those should expose slots and modifiers, not state holders.compose-ui-testing-patterns — testing plain state-driven UI composables without the full app graph.compose-state-hoisting — deciding where UI element state and UI logic should live, including plain state holder classes.kotlin-multiplatform-expect-actual — platform services, native views, and expect/interface boundaries when shared UI meets platform-specific leaves.