
โ Have you ever wondered, โWhy does my screen state keep getting more and more complicated when building with Compose?โ
When building Android apps, state multiplies quickly.
User input, server data, loading/error flags, temporary UI toggles, animation triggersโฆ At first it feels convenient to throw everything into a single ViewModel, but soon youโll hit unexpected recompositions, tangled dependencies, and increased testing complexity all at once. Compose makes this even trickier since its expressiveness allows complex formulas to sneak in so easily.
This post is a practical guide to reduce that complexity by clearly separating Business Logic State Holders and UI Logic State Holders. Itโs based on the painful lessons I learned from both a coding test assignment at a tech company and the Woowacourse Android Mission, and the final approach I applied to a Card Registration Screen.
Hereโs how the post is structured:
Concept โ Decision Criteria โ Code Patterns โ Refactoring Steps โ Testing Strategy โ Checklist
If you remember just one line, itโs this:
โ โBusiness lives in the ViewModel, interactions live in the UI State Holder.โ
1. Clearer UDF (Unidirectional Data Flow)
โ Simpler event flow and easier debugging
2. Minimized Recomposition Cost
3. Easier Testing and Substitution
| Question | Example | Goes Into |
|---|---|---|
| Must persist after rotation/process death? | Login state, detail info | Business (ViewModel) |
| Needs external layer access/async work? | Repository/UseCase calls, caching | Business (ViewModel) |
| Purely temporary UI-only state? | Input validation, bottom sheet open flag | UI State Holder |
| Reusable UI pattern/logic? | TextField with local validation, scroll snapping | UI State Holder (small class) |
Input formatting, validation, sheet toggles โ
pure UI logic.
@Stable
class NewCardUiStateHolder(
isBankSheetOpenInit: Boolean = false
) {
var isBankSheetOpen by mutableStateOf(isBankSheetOpenInit)
private set
fun updateBankSheet(open: Boolean) {
isBankSheetOpen = open
}
val canSave: Boolean
get() = /* Combine validation results here */
}
@Composable
fun rememberNewCardState(): NewCardUiStateHolder =
rememberSaveable { NewCardUiStateHolder() }
@Composable
fun NewCardScreen(
onSaved: (CardUiModel) -> Unit = {},
onFinish: () -> Unit = {},
) {
val holder = rememberNewCardState()
val viewModel: NewCardViewModel = hiltViewModel()
NewCardTopBar(
onBackClick = onFinish,
onSaveClick = {
if (holder.canSave) {
val model = holder.createCardUiModel()
viewModel.save(model) // delegate to business
onSaved(model) // optional callback
}
}
)
// Preview card click โ holder.updateBankSheet(true)
// TextFields bind only to holder.updateXXX(...)
// Save button enabled/disabled via holder.canSave
}
Key Points:
Rule of thumb:
โInstant response = UI, accountable business logic = ViewModel.โ
@Test fun `card number accepts only 16 digits`() {
val ui = NewCardUiStateHolder()
ui.updateCardNumber("1234-5678-9012-3456-9999")
assertEquals(16, ui.cardNumber.length)
}
@Test fun `saving succeeds and updates state to Saved`() = runTest {
val viewModel = NewCardViewModel(fakeRepository)
val model = CardUiModel("1234567812345678", "1225", "HOLDER", BankType.KB)
viewModel.save(model)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
NewCardUiState.Saved(model),
viewModel.uiState.value
)
}
(Of course, conventions may vary)
โBusiness lives in the ViewModel, interactions live in the UI State Holder.โ
Stick to this one line and your screens instantly become simpler:
If youโre not sure where to start, ask yourself:
โWhatโs only needed in the UI?โ Then encapsulate it into a small UI State Holder. Your code will feel instantly lighter.