Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

Compose Beyond the UI: Architecting Reactive St...

Avatar for Adit Lal Adit Lal
December 06, 2025

Compose Beyond the UI: Architecting Reactive State Machines at Scale

Jetpack Compose has redefined how we build UIs... but most apps still fall back on ViewModels and scattered logic that don’t scale well. As complexity grows, this leads to inconsistent screens, hard-to-reproduce bugs, and duplicated code. In this talk, we’ll look at how to move beyond MVVM/MVI into a reactive state machine model that fits Compose’s declarative style. You’ll learn how to design clean state contracts, coordinate transitions across navigation stacks and dialogs, and handle side effects like navigation or snackbars in a predictable way. We’ll also cover async patterns, modularizing domain state, debugging techniques for tracking state evolution, and testing UIs with simulated states and time-based transitions. By the end, you’ll leave with practical techniques to make Compose apps more scalable, debuggable, and easier to maintain.

Avatar for Adit Lal

Adit Lal

December 06, 2025
Tweet

More Decks by Adit Lal

Other Decks in Technology

Transcript

  1. Patterns for predictable, testable, scalable UI state Compose Beyond the

    UI: Architecting Reactive State Machines at Scale Adit Lal GDE Android
  2. Wh a t bre a ks a t sc a

    le Why it bre a ks? Build ment a l models How to f ix it I d e n t i f y P a t t e rns So l u t ions Compose Beyond the UI: Architecting Reactive State Machines at Scale Patterns for predictable, testable, scalable UI state
  3. - Common bugs th a t seem r a ndom

    - Symptoms vs root c a uses - The "it works on my m a chine" problem I d e n t i f y P e So t
  4. - St a te t a xonomy (ephemer a l

    → screen → dom a in) - Boole a n explosion → st a te m a chines - T a ngled tr a nsitions → pure functions y P a t t e rns So t
  5. - St a - Boole a - T a y

    P e So l u t ions - Se a led interf a ces for impossible st a tes - Tr a nsitionResult for e ff ects a s outputs - Testing without mocks
  6. Three Bugs Everyone Has - Sn a ckb a r

    shows a g a in a fter rot a tion
  7. Three Bugs Everyone Has - Sn a ckb a r

    shows a g a in a fter rot a tion - Button Fires twice on f a st t a p
  8. Three Bugs Everyone Has - Sn a ckb a r

    shows a g a in a fter rot a tion - Button Fires twice on f a st t a p - Screen shows st a le d a t a a fter b a ck n a vig a tion
  9. Three Bugs Everyone Has - Sn a - Button Fires

    twice on f a - Screen shows st a These Aren't Compose Bugs These are State bugs
  10. Three Bugs Everyone Has - Sn a - Button Fires

    twice on f a - Screen shows st a These Aren't Compose Bugs These are State bugs The Three Layers of State
  11. Ephemeral remember { } Screen ViewModel Domain Repository Form Screen

    The Three Layers of State Where does e a ch st a te live? Wrong Layer == Bugs
  12. Ephemeral remember { } Screen ViewModel Domain Repository Form Screen

    The Three Layers of State Where does e a ch st a te live? TextField focus state Keyboard visible/hidden Scroll position Wrong Layer == Bugs
  13. Ephemeral remember { } Screen ViewModel Domain Repository Form Screen

    The Three Layers of State Where does e a ch st a te live? TextField focus state Keyboard visible/hidden Scroll position Form values (name, etc) Validation errors isLoading, isSaving Wrong Layer == Bugs
  14. Ephemeral remember { } Screen ViewModel Domain Repository Form Screen

    The Three Layers of State Where does e a ch st a te live? TextField focus state Keyboard visible/hidden Scroll position Form values (name, etc) Validation errors isLoading, isSaving User pro fi le (from API) Saved pro fi le (in DB) Wrong Layer == Bugs
  15. Wrong Layer == Bugs Ephemeral remember { } Screen ViewModel

    Domain Repository Form Screen The Three Layers of State The Rule
  16. Wrong Layer == Bugs Ephemeral remember { } Screen ViewModel

    Domain Repository Form Screen The Three Layers of State Screen state here Form resets on rotation The Rule
  17. Wrong Layer == Bugs Ephemeral remember { } Screen ViewModel

    Domain Repository Form Screen The Three Layers of State Screen state here Form resets on rotation Ephemeral state in ViewModel Keyboard survives navigation The Rule
  18. Wrong Layer == Bugs Ephemeral remember { } Screen ViewModel

    Domain Repository Form Screen The Three Layers of State Screen state here Form resets on rotation Ephemeral state in ViewModel Keyboard survives navigation Screen logic trapped here Stale data a ft er back nav The Rule
  19. The Three Layers of State The Rule W h e

    n Shou l d t h e s t at e di e ? State Machines
  20. The Three Layers of State The Rule - Dies with

    recomposition → remember { } W h e n Shou l d t h e s t at e di e ? State Machines
  21. The Three Layers of State The Rule - Dies with

    recomposition → remember { } - Dies with n a vig a tion → ViewModel W h e n Shou l d t h e s t at e di e ? State Machines
  22. The Three Layers of State The Rule - Dies with

    recomposition → remember { } - Dies with n a vig a tion → ViewModel - Never dies → Repository W h e n Shou l d t h e s t at e di e ? State Machines
  23. State Machines The Boole a n Explosion Problem data class

    Sc r eenState( val isLoading: Boolean = false, val isSaving: Boolean = false, val isE r r o r : Boolean = false, val isSuccess: Boolean = false, ) ViewModel State booleans 🫣
  24. State Machines The Boole a n Explosion Problem data class

    Sc r eenState( val isLoading: Boolean = false, val isSaving: Boolean = false, val isE r r o r : Boolean = false, val isSuccess: Boolean = false, ) 2⁴ = 16 possible combinations ViewModel State booleans 🫣
  25. State Machines The Boole a n Explosion Problem data class

    Sc r eenState( val isLoading: Boolean = false, val isSaving: Boolean = false, val isE r r o r : Boolean = false, val isSuccess: Boolean = false, ) 2⁴ = 16 possible combinations On l y 4 a r e v al id ViewModel State booleans 🫣
  26. State Machines The Boole a n Explosion Problem data class

    Sc r eenState( val isLoading: Boolean = false, val isSaving: Boolean = false, val isE r r o r : Boolean = false, val isSuccess: Boolean = false, ) 2⁴ = 16 possible combinations On l y 4 a r e v al id
  27. State Machines The Boole a n Explosion Problem data class

    Sc r eenState( val isLoading: Boolean = false, val isSaving: Boolean = false, val isE r r o r : Boolean = false, val isSuccess: Boolean = false, ) 2⁴ = 16 possible combinations On l y 4 a r e v al id ViewModel
  28. State Machines The Boole a n Explosion Problem data class

    Sc r eenState( val isLoading: Boolean = false, val isSaving: Boolean = false, val isE r r o r : Boolean = false, val isSuccess: Boolean = false, ) 2⁴ = 16 possible combinations On l y 4 a r e v al id ViewModel State booleans 🫣
  29. State Machines The Boole a n Explosion Problem data class

    Sc r eenState( val isLoading: Boolean = false, val isSaving: Boolean = false, val isE r r o r : Boolean = false, val isSuccess: Boolean = false, ) 2⁴ = 16 possible combinations On l y 4 a r e v al id ViewModel State booleans 🫣 E v e r y a c t ion c a n t ouch e v e r y fl a g
  30. State Machines Se a led Interf a ce Solution M

    a ke IMPOSSIBLE STATES impossible
  31. sealed inte r face Sc r eenState { data object

    Loading : Sc r eenState data class Editing(val fo r m: Fo r m) : Sc r eenState data class Saving(val fo r m: Fo r m) : Sc r eenState data class Success(val msg: St r ing) : Sc r eenState data class E r r o r (val r eason: St r ing) : Sc r eenState } State Machines Se a led Interf a ce Solution M a ke IMPOSSIBLE STATES impossible
  32. sealed inte r face Sc r eenState { data object

    Loading : Sc r eenState data class Editing(val fo r m: Fo r m) : Sc r eenState data class Saving(val fo r m: Fo r m) : Sc r eenState data class Success(val msg: St r ing) : Sc r eenState data class E r r o r (val r eason: St r ing) : Sc r eenState } State Machines Se a led Interf a ce Solution M a ke IMPOSSIBLE STATES impossible 5 s t at e s. On ly 5. N o combin at ions.
  33. UniDirectional Pipe UI Events ViewModel Tr a nsition St a

    te (se a led) UI Render Observes State Machines
  34. UniDirectional Pipe State Machines Lo a ding onLoad d a

    t a lo a ded Editing Error Error Retry
  35. UniDirectional Pipe State Machines Lo a ding onLoad d a

    t a lo a ded Editing Error S a ving Error Retry
  36. UniDirectional Pipe State Machines Lo a ding onLoad d a

    t a lo a ded Editing Error S a ving Success Error Retry Success
  37. UniDirectional Pipe State Machines Lo a ding onLoad d a

    t a lo a ded Editing Error S a ving Success Error Retry Success E a ch a rrow is a v a lid tr a nsition
  38. UniDirectional Pipe State Machines Lo a ding onLoad d a

    t a lo a ded Editing Error S a ving Success Error Retry Success E a ch a rrow is a v a lid tr a nsition Missing a rrow = impossible tr a nsition
  39. UniDirectional Pipe State Machines VIEW INTENT MODEL VIEW (UI) (Event)

    (State) (UI) Use r ──────> Submit ──────> Saving ──────> Show taps button Intent State Spinne r
  40. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap var shouldProcess by

    remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } State Anti-Patterns
  41. var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess)

    { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline
  42. var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess)

    { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns // ✅ Runs Timeline
  43. var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess)

    { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns // ✅ Runs // 💀 Triggers c a ncell a tion Timeline
  44. var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess)

    { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns // ✅ Runs // 💀 Triggers c a ncell a tion // ❌ NEVER RUNS Timeline
  45. var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess)

    { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns // ✅ Runs // 💀 Triggers c a ncell a tion // ⏸ Suspension point // ❌ NEVER RUNS Timeline
  46. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } }
  47. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    shouldProcess = true —> L a nchedE ff ect st a rts var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } }
  48. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    shouldProcess = true —> L a nchedE ff ect st a rts var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } doSomething() —> completes
  49. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    shouldProcess = true —> L a nchedE ff ect st a rts var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } doSomething() —> completes shouldProcess = f a lse —> Key Ch a nged, c a ncell a tion scheduled
  50. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    shouldProcess = true —> L a nchedE ff ect st a rts var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } doSomething() —> completes shouldProcess = f a lse —> Key Ch a nged, c a ncell a tion scheduled del a y(200) —> Coroutine suspends
  51. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    shouldProcess = true —> L a nchedE ff ect st a rts var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } doSomething() —> completes shouldProcess = f a lse —> Key Ch a nged, c a ncell a tion scheduled del a y(200) —> Coroutine suspends Recomposition runs (~16ms)
  52. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    shouldProcess = true —> L a nchedE ff ect st a rts var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } doSomething() —> completes shouldProcess = f a lse —> Key Ch a nged, c a ncell a tion scheduled del a y(200) —> Coroutine suspends Recomposition runs (~16ms) Key is ch a nged
  53. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    shouldProcess = true —> L a nchedE ff ect st a rts var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } doSomething() —> completes shouldProcess = f a lse —> Key Ch a nged, c a ncell a tion scheduled del a y(200) —> Coroutine suspends Recomposition runs (~16ms) Key is ch a nged C a ncels old L a unchedE ff ect
  54. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Timeline

    shouldProcess = true —> L a nchedE ff ect st a rts var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(shouldProcess) { if (shouldProcess) { doSomething() shouldProcess = false delay(200) doImportantWork() } } doSomething() —> completes shouldProcess = f a lse —> Key Ch a nged, c a ncell a tion scheduled del a y(200) —> Coroutine suspends Recomposition runs (~16ms) Key is ch a nged C a ncels old L a unchedE ff ect doImport a ntWork() —> never re a ched
  55. ANTI-PATTERN #1: LaunchedE ff ect Self-Cancellation Trap State Anti-Patterns Fix

    var shouldProcess by remember { mutableStateOf(false) } LaunchedE ff ect(Unit) { // Key never changes snapshotFlow { shouldProcess } . fi lter { it } .collect { doSomething() shouldProcess = false // Safe now delay(200) doImportantWork() // ✅ Runs } } Key is st a ble. St a te ch a nge doesn't kill the coroutine.
  56. Rule State Anti-Patterns LAUNCHEDEFFECT RULE If you ch a nge

    the key inside the e ff ect... ... a nd you h a ve a ny suspension point a fter...
  57. Rule State Anti-Patterns LAUNCHEDEFFECT RULE If you ch a nge

    the key inside the e ff ect... Key = lifecycle Ch a nge key = rest a rt lifecycle Rest a rt = c a ncel previous ... a nd you h a ve a ny suspension point a fter... ...your code a fter th a t point won't run.
  58. Transitions Are Functions data class T r ansitionResult( val newState:

    Sc r eenState, val effects: List<Effect> = emptyList() ) fun ScreenState.onSubmit(): TransitionResult = when (this) { is Loading -> TransitionResult(this) is Editing -> TransitionResult( newState = Saving(form), e fi ) is Saving -> TransitionResult(this) is Success -> TransitionResult(this)
  59. Transitions Are Functions data class T r ansitionResult( val newState:

    Sc r eenState, val effects: List<Effect> = emptyList() ) fun ScreenState.onSubmit(): TransitionResult = when (this) { is Loading -> TransitionResult(this) is Editing -> TransitionResult( newState = Saving(form), e ff ects = listOf(E ff ect.SavePro fi le(form)) ) is Saving -> TransitionResult(this) is Success -> TransitionResult(this) is Error -> TransitionResult( newState = Saving(form), e ff ects = listOf(E ff ect.SavePro fi le(form)) ) }
  60. Transitions Are Functions r val newState: Sc r val effects:

    List<Effect> = emptyList() ) fun ScreenState.onSubmit(): TransitionResult = when (this) { is Loading -> TransitionResult(this) is Editing -> TransitionResult( newState = Saving(form), e ff ects = listOf(E ff ect.SavePro fi le(form)) ) is Saving -> TransitionResult(this) is Success -> TransitionResult(this) is Error -> TransitionResult( newState = Saving(form), e ff ects = listOf(E ff ect.SavePro fi le(form)) ) }
  61. Transitions Are Functions r val newState: Sc r val effects:

    List<Effect> = emptyList() ) fun ScreenState.onSubmit(): TransitionResult = when (this) { is Loading -> TransitionResult(this) is Editing -> TransitionResult( newState = Saving(form), e ff ects = listOf(E ff ect.SavePro fi le(form)) ) is Saving -> TransitionResult(this) is Success -> TransitionResult(this) is Error -> TransitionResult( newState = Saving(form), e ff ects = listOf(E ff ect.SavePro fi le(form)) ) }
  62. Transitions Are Functions r val newState: Sc r val effects:

    List<Effect> = emptyList() ) fun ScreenState.onSubmit(): TransitionResult = when (this) { is Loading -> TransitionResult(this) is Editing -> TransitionResult( newState = Saving(form), e ff ects = listOf(E ff ect.SavePro fi le(form)) ) is Saving -> TransitionResult(this) is Success -> TransitionResult(this) is Error -> TransitionResult( newState = Saving(form), e ff ects = listOf(E ff ect.SavePro fi le(form)) ) } Pure function. No API calls inside. No side e ff ects. Just data in, data out. Why Pure functions?
  63. Why Pure functions? PREDICTABLE S a me input → s

    a me output. Alw a ys. Effects as Outputs
  64. Why Pure functions? PREDICTABLE S a me input → s

    a me output. Alw a ys. TESTABLE No mocks. No coroutines. Just a ssertions. Effects as Outputs
  65. Why Pure functions? PREDICTABLE S a me input → s

    a me output. Alw a ys. TESTABLE No mocks. No coroutines. Just a ssertions. DEBUGGABLE Log every tr a nsition. Repl a y a ny bug. Effects as Outputs
  66. Effects as Outputs Side E ff ect a re outputs,

    not inline code // ❌ E ff ect inside transition fun onSubmit() { _state.value = Saving(form) repository.save(form) // Side e ff ect here navigator.goTo(Success) // Another one }
  67. Effects as Outputs Side E ff ect a re outputs,

    not inline code // ❌ E ff ect inside transition fun onSubmit() { _state.value = Saving(form) repository.save(form) // Side e ff ect here navigator.goTo(Success) // Another one } // ✅ E ff ects as data fun ScreenState.onSubmit() = TransitionResult( newState = Saving(form), e ff ects = listOf( E ff ect.SavePro fi le(form), E ff ect.Navigate(Routes.Success) ) )
  68. Effect Types Common E ff ects sealed inte r face

    Effect { data class ShowSnackba r ( val message: St r ing, val id: UUID = UUID. r andomUUID() ) : Effect data class Navigate(val r oute: St r ing) : Effect data class SaveToDatabase(val data: Fo r m) : Effect data class T r ackAnalytics(val event: St r ing) : Effect data object HapticFeedback : Effect } ViewModel Executes
  69. ViewModel Executes Effect Types class P r of i leViewModel

    : ViewModel() { p r ivate val _state = MutableStateF l ow<Sc r eenState>(Loading) val state = _state.asStateF l ow() fun onSubmit() { val r esult = _state.value.onSubmit() _state.value = r esult.newState r esult.effects.fo r Each { execute(it) } } p r ivate fun execute(effect: Effect) = when (effect) { is Effect.SaveP r of i le - > viewModelScope.launch { r eposito r y.save(effect.fo r m) } is Effect.ShowSnackba r - > _snackba r .emit(effect) is Effect.Navigate - > _navigation.emit(effect. r oute) } }
  70. ViewModel Executes Effect Types class P r of i leViewModel

    : ViewModel() { p r ivate val _state = MutableStateF l ow<Sc r eenState>(Loading) val state = _state.asStateF l ow() fun onSubmit() { val r esult = _state.value.onSubmit() _state.value = r esult.newState r esult.effects.fo r Each { execute(it) } } p r ivate fun execute(effect: Effect) = when (effect) { is Effect.SaveP r of i le - > viewModelScope.launch { r eposito r y.save(effect.fo r m) } is Effect.ShowSnackba r - > _snackba r .emit(effect) is Effect.Navigate - > _navigation.emit(effect. r oute) } }
  71. ViewModel Executes Effect Types class P r of i leViewModel

    : ViewModel() { p r ivate val _state = MutableStateF l ow<Sc r eenState>(Loading) val state = _state.asStateF l ow() fun onSubmit() { val r esult = _state.value.onSubmit() _state.value = r esult.newState r esult.effects.fo r Each { execute(it) } } p r ivate fun execute(effect: Effect) = when (effect) { is Effect.SaveP r of i le - > viewModelScope.launch { r eposito r y.save(effect.fo r m) } is Effect.ShowSnackba r - > _snackba r .emit(effect) is Effect.Navigate - > _navigation.emit(effect. r oute) } }
  72. Async State Machines ASYNC = EXPLICIT STATES sealed inte r

    face Async<out T> { data object Idle : Async<Nothing> data object Loading : Async<Nothing> data class Success<T>(val data: T) : Async<T> data class E r r o r (val e r r o r : Th r owable) : Async<Nothing> } / / Usage: data class P r of i leSc r eenState( val p r of i le: Async<Use r P r of i le> = Async.Idle, val saveStatus: Async<Unit> = Async.Idle )
  73. Async State Machines WHY ASYNC<T>? C a ncell a tion

    Job c a ncelled → b a ck to Idle Not stuck in Loading forever
  74. Async State Machines WHY ASYNC<T>? C a ncell a tion

    Job c a ncelled → b a ck to Idle Not stuck in Loading forever MULTIPLE REQUESTS E a ch oper a tion gets its own Async<T> No boolean fl ags colliding
  75. Async State Machines WHY ASYNC<T>? C a ncell a tion

    Job c a ncelled → b a ck to Idle Not stuck in Loading forever MULTIPLE REQUESTS E a ch oper a tion gets its own Async<T> No boolean fl ags colliding RETRY Error holds throw a ble. User taps retry → Loading again
  76. Testing TESTING: NO MOCKS NEEDED @Test fun `submit f r

    om Editing t r ansitions to Saving`() { val state = Sc r eenState.Editing(fo r m = testFo r m) val r esult = state.onSubmit() asse r tThat( r esult.newState).isEqualTo(Saving(testFo r m)) asse r tThat( r esult.effects).containsExactly( Effect.SaveP r of i le(testFo r m) ) }
  77. Testing TESTING: NO MOCKS NEEDED @Test fun `submit while Saving

    is igno r ed`() { val state = Sc r eenState.Saving(fo r m = testFo r m) val r esult = state.onSubmit() asse r tThat( r esult.newState).isEqualTo(Saving(testFo r m)) asse r tThat( r esult.effects).isEmpty() }
  78. Avoiding AntiPatterns Sn a ckb a r Shows Twice //

    Bad: ❌ State-based one-time event val error by viewModel.error.collectAsState() LaunchedE ff ect(error) { error?.let { snackbarHostState.showSnackbar(it) // Rotation → resubscribes → shows again } }
  79. Avoiding AntiPatterns Sn a ckb a r Shows Twice //

    Good: ✅ E ff ect with unique ID data class ShowSnackbar( val message: String, val id: UUID = UUID.randomUUID() ) LaunchedE ff ect(e ff ect?.id) { e ff ect?.let { snackbarHostState.showSnackbar(it.message) viewModel.consumeE ff ect() } }
  80. Avoiding AntiPatterns Forgotten Remember / / Bad: ❌ New instance

    eve r y r ecomposition @Composable fun Counte r () { va r count = mutableStateOf(0) / / No r emembe r ! Button(onClick = { count.value + + }) { Text("Count: ${count.value}") / / Always 0 } }
  81. Avoiding AntiPatterns / / Good:✅ Su r vives r ecomposition

    @Composable fun Counte r () { va r count by r emembe r { mutableStateOf(0) } Button(onClick = { count + + }) { Text("Count: $count") } } Forgotten Remember
  82. Avoiding AntiPatterns B a ckw a rds Write = In

    f inite Loop / / Bad:❌ W r ite afte r r ead = inf i nite r ecomposition @Composable fun B r oken() { va r count by r emembe r { mutableStateOf(0) } Text("$count") / / Read count + + / / W r ite → t r igge r s r ecomposition → r epeat }
  83. Avoiding AntiPatterns / / Good: ✅ W r ite only

    in callbacks @Composable fun Fixed() { va r count by r emembe r { mutableStateOf(0) } Text("$count") Button(onClick = { count + + }) { / / W r ite in event Text("Inc r ement") } } Forgotten Remember
  84. Avoiding AntiPatterns derivedSt a teOf Misue / / Useless: ❌

    Output changes as often as input - no benef i t va r f i r stName by r emembe r { mutableStateOf("") } va r lastName by r emembe r { mutableStateOf("") } val fullName by r emembe r { de r ivedStateOf { "$f i r stName $lastName" } / / Pointless }
  85. Avoiding AntiPatterns derivedSt a teOf Misue / / ✅ Just

    compute it val fullName = "$f i r stName $lastName" Co r r ect use: / / ✅ Output changes LESS than input val listState = r emembe r LazyListState() val showButton by r emembe r { de r ivedStateOf { listState.f i r stVisibleItemIndex > 0 } } / / Sc r oll position changes eve r y pixel / / Boolean changes r a r ely
  86. Avoiding AntiPatterns collectAsSt a te vs collectAsSt a teWithLifecycle Wasteful:

    / / ❌ Keeps collecting when app backg r ounded val state by viewModel.state.collectAsState() Bette r : / / ✅ Lifecycle - awa r e - pauses when STOPPED val state by viewModel.state.collectAsStateWithLifecycle() collectAsState: App backg r ounded → still collecting → batte r y d r ain collectAsStateWithLifecycle: App backg r ounded → paused → r esumes when visible
  87. Avoiding AntiPatterns Unst a ble L a mbd a C

    a ptures / / Bad: ❌ New lambda eve r y r ecomposition @Composable fun Pa r ent() { va r text by r emembe r { mutableStateOf("") } Child(onClick = { doSomething() }) / / New instance each time TextField(value = text, onValueChange = { text = it }) } / / TextField updates → Pa r ent r ecomposes → new lambda → Child r ecomposes
  88. Avoiding AntiPatterns Unst a ble L a mbd a C

    a ptures / / Good: ✅ Stable lambda @Composable fun Pa r ent() { va r text by r emembe r { mutableStateOf("") } val onClick = r emembe r { { doSomething() } } Child(onClick = onClick) / / Same instance TextField(value = text, onValueChange = { text = it }) }
  89. Climax Four Rules Sep a r a te Model Emit

    Test State lives where its lifecycle matches
  90. Climax Four Rules Sep a r a te Model Emit

    Test Sealed inte rf aces, not boolean bags State lives where its lifecycle matches
  91. Climax Four Rules Sep a r a te Model Emit

    Test Sealed inte rf aces, not boolean bags State lives where its lifecycle matches E ff ects as outputs, not inline code
  92. Climax Four Rules Sep a r a te Model Emit

    Test Sealed inte rf aces, not boolean bags State lives where its lifecycle matches E ff ects as outputs, not inline code Pure functions, no mocks