Compose Business Logic and UI State Holder Guide

GongBaekยท2025๋…„ 9์›” 17์ผ
post-thumbnail

โ€” 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


0) TL;DR

  • Business Logic State Holder: Independent of the screen lifecycle. Responsible for data source access/processing and business rules. Usually implemented as a ViewModel.
  • UI Logic State Holder: Tied to the screen lifecycle. Handles pure UI concerns like checkboxes, input validation, scroll position, dialogs. Usually implemented with remember/rememberSaveable + small classes.

If you remember just one line, itโ€™s this:

โ€” โ€œBusiness lives in the ViewModel, interactions live in the UI State Holder.โ€


1) Why Separate Them?

1. Clearer UDF (Unidirectional Data Flow)

  • ViewModel โ†’ domain/data access + mapping to UI models
  • UI Holder โ†’ input/interaction handling (formatting, validation, toggles)

โ†’ Simpler event flow and easier debugging

2. Minimized Recomposition Cost

  • Localize UI-only state with remember โ†’ reduces unnecessary observation and allows partial recomposition

3. Easier Testing and Substitution

  • ViewModel tests = business rules
  • UI Holder tests = โ€œinput โ†’ stateโ€ with small, pure logic

2) What Goes Where? (Decision Table)

QuestionExampleGoes Into
Must persist after rotation/process death?Login state, detail infoBusiness (ViewModel)
Needs external layer access/async work?Repository/UseCase calls, cachingBusiness (ViewModel)
Purely temporary UI-only state?Input validation, bottom sheet open flagUI State Holder
Reusable UI pattern/logic?TextField with local validation, scroll snappingUI State Holder (small class)

3) Minimal Pattern: โ€œViewModel is Business, UI is Localizedโ€

3.1 Card Wallet โ€” UI State Holder

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() }
  • isBankSheetOpen โ†’ purely UI toggles should live here
  • canSave โ†’ instant feedback calculations should live here
  • Final saving, duplicate checks, business rules โ†’ ViewModel

3.2 Screen Composition (Card Wallet, minimal wiring)

@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:

  • Source of truth / final saving โ†’ ViewModel
  • Input formatting / sheet toggling / button enabling โ†’ UI State Holder
  • The screen only loosely wires the two together

4) One Step Further: Where to Draw the Line?

  • Needs instant UI feedback char limits, local formatting, toggles, focus changes, bottom sheet open flags โ†’ Belongs in UI Holder
  • Requires policy enforcement, persistence, or server checks duplicate card checks, Luhn/BIN validation, saving to server, retries, logging โ†’ Belongs in ViewModel + domain/data layers

Rule of thumb:

โ€œInstant response = UI, accountable business logic = ViewModel.โ€


5) Performance Tips

  • Use derivedStateOf { ... } for caching computed values (e.g. button enabled state, summary text, counters)
  • Use snapshotFlow { state } to minimize read points โ†’ clearer handling, avoids unnecessary loops
  • Use rememberSaveable only for recoverable values โ†’ form inputs that must survive process death; otherwise remember is enough
  • For lists: use LazyXxx + stable keys, and keep item state inside items

6) Refactoring Steps (When Everything is in ViewModel)

  1. Make a split table
    • Needs server/cache/domain access? โ†’ ViewModel
    • Pure UI-only? โ†’ UI Holder
  2. Start with UI State Holder design
    • Public API should reflect user actions (updateXxx, toggle())
  3. Inject it via remember/rememberSaveable
    • Sync initial values from ViewModelโ€™s StateFlow if needed
  4. Verify ViewModel noise is gone
    • If input formats/focus/visibility are removed, success
  5. Separate tests
    • ViewModel = business transitions
    • UI Holder = pure input โ†’ state

7) Testing Strategy

UI State Holder Unit Tests: Lightweight

  • Test toggles, derived states, and simple validation
  • No Compose runtime needed โ€” pure Kotlin tests
@Test fun `card number accepts only 16 digits`() {
    val ui = NewCardUiStateHolder()
    ui.updateCardNumber("1234-5678-9012-3456-9999")
    assertEquals(16, ui.cardNumber.length)
}

ViewModel Tests: Focus on Business Transitions

  • Test save success/failure, duplicate checks, retry logic, loading/error flags
  • Should run fine without the UI Holder
@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
    )
}

8) Pitfalls

  • Shoving all inputs/toggles into ViewModel โ†’ triggers excessive observation + recomposition hell. Localize into UI.
  • Storing mutable objects directly in remember โ†’ breaks referential equality checks. Use immutable + copy or mutableStateOf.
  • Overusing derivedStateOf โ†’ only use for expensive computations; otherwise just compute directly.

9) Naming & Structure Guide

(Of course, conventions may vary)

  • UI-only: XxxUiState, XxxController, etc.
    • Provide rememberXxxUiState() if Saver exists
  • Business-only: XxxViewModel
    • Expose only UI models (UiModel/UiState), do domain โ†’ UI mapping internally
  • Event naming: updateXxx, toggleXxx, etc. โ†’ action-oriented

10) Checklist

  • Does your ViewModel hold UI-only values (focus, open flags, input formats)?
  • Is the UI Holder API action-centered?
  • Is derivedStateOf used only for expensive computations?
  • Is rememberSaveable applied only where recovery is required?
  • Are ViewModel and UI Holder tests separated?

11) Wrap-Up

โ€œBusiness lives in the ViewModel, interactions live in the UI State Holder.โ€

Stick to this one line and your screens instantly become simpler:

  • Clearer separation of responsibilities
  • Lower recomposition cost
  • Easier testing and maintenance

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.

profile
Junior Android Developer

0๊ฐœ์˜ ๋Œ“๊ธ€