Compose Multiplatform ViewModels: Initializer Rules, NavEntry Scopes, and the iOS SharedFlow Replay Trap
Teams sharing ViewModel code across Android and non-JVM targets need these constraints up front; the iOS SharedFlow replay bug alone can replay navigation events and send users back to screens they already left.
Compose Multiplatform's ViewModel code has sharp edges that only show up off Android. On iOS and Wasm/JS, type reflection is unavailable, so every `viewModel()` call in commonMain must pass an initializer; the parameterless `viewModel<MyViewModel>()` form compiles on Android but crashes at runtime elsewhere. Desktop targets also need `kotlinx-coroutines-swing` because `viewModelScope` binds to `Dispatchers.Main.immediate`.
Scope management is where most architecture decisions land. In a single-Activity Compose app, the default ViewModelStoreOwner is the Activity, not the current screen, so ViewModels survive navigation and can leak. Navigation 3 fixes this with entry decorators: `rememberViewModelStoreNavEntryDecorator()` scopes a ViewModel to a NavEntry, and it must be paired with `rememberSaveableStateHolderNavEntryDecorator()` so `SavedStateHandle` works.
State and events follow two patterns. UI state should be one immutable data class exposed through a single StateFlow, avoiding the inconsistent intermediate states that multiple StateFlows produce. One-shot effects such as navigation and snackbars should use a Channel, because SharedFlow's replay cache has a known iOS bug that re-emits navigation events when a user returns to a screen. iOS also does not cancel `viewModelScope` when the app is backgrounded, so coroutine lifecycles need explicit management.
The non-JVM initializer constraint turns a familiar Android API into a runtime-only failure: code that works on Android compiles everywhere and then crashes on iOS, which makes the no-argument `viewModel()` call a legitimate code-review ban.
The iOS SharedFlow replay issue shows platform divergence inside shared code; Android's snapshot/lifecycle coordination hides the problem, so lifecycle-aware behavior is not identical across targets.
Scoping ViewModels to NavEntry matters most because default Activity scoping silently keeps screen state alive across navigation, which is both useful and a memory-leak source.
KMP's testing guidance to prefer Fakes over Mock frameworks follows from platform constraints: reflection-based mocking does not port cleanly to every target, so the test strategy changes even when production code does not.
The single UiState data class pattern changes atomicity: multiple StateFlows can render a loading=false/empty-list frame during updates, while one immutable data class keeps state consistent.