Choosing the right state container in Compose Multiplatform: remember vs rememberSaveable vs ViewModel vs rememberCoroutineScope. Covers: what survives recomposition, config changes, and process death; when each container applies; rememberSaveable with custom Saver for complex types; ViewModel scoping to nav back-stack entries; and the most common wrong choices (ViewModel for dropdown state, remember for form data that must survive rotation). Zero new dependencies.
Installs into .claude/skills of the current project.
Are you the author of Kmp Compose State Container?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ronjunevaldoz-kmp-compose-state-container)
---
name: kmp-compose-state-container
description: >
Choosing the right state container in Compose Multiplatform: remember vs
rememberSaveable vs ViewModel vs rememberCoroutineScope. Covers: what survives
recomposition, config changes, and process death; when each container applies;
rememberSaveable with custom Saver for complex types; ViewModel scoping to
nav back-stack entries; and the most common wrong choices (ViewModel for dropdown
state, remember for form data that must survive rotation). Zero new dependencies.
license: Apache-2.0
metadata:
author: kmp-agent-skills
last-updated: '2026-06-06'
keywords:
- remember
- rememberSaveable
- ViewModel
- state container
- rememberCoroutineScope
- custom Saver
- state survival
- config change
- process death
- back stack scoped ViewModel
- nav-scoped ViewModel
- Compose state
- Kotlin Multiplatform
- CMP
- when to use ViewModel
- ephemeral state
---
## When to Use This Skill
Use when you need to:
- Decide whether state belongs in `remember`, `rememberSaveable`, or a `ViewModel`
- Understand what "survives recomposition" vs "survives config change" actually means
- Implement a custom `Saver` for `rememberSaveable` with a non-bundleable type
- Scope a ViewModel to a nav back-stack entry vs the whole nav graph
- Diagnose state being lost on screen rotation or back navigation
**Trigger keywords:** remember vs ViewModel, rememberSaveable, state container, when to use
ViewModel, ephemeral state, state survival, config change state, process death state,
custom Saver, nav-scoped ViewModel, state lost on rotation.
**Freshness rule:** Compose state survival and nav-scoped ViewModel APIs change with lifecycle
and navigation releases — recheck the Jetpack and CMP docs before upgrading.
---
## Recommendation First
Default decision: **ephemeral UI state → `remember`; form/input state → `rememberSaveable`;
business data or screen state → `ViewModel`**.
Why:
- `remember` is enough for transient state (dropdown open, tooltip visible) — no persistence needed
- `rememberSaveable` survives config changes, preventing form input loss on rotation
- `ViewModel` is the correct boundary for state that outlives a single screen instance and must
survive the back-stack navigation lifecycle
Do not put business logic or domain objects inside `remember` or `rememberSaveable` — that belongs
in the ViewModel, not the composition.
---
## What Each Container Survives
| Container | Recomposition | Config change (rotation) | Process death | Scope |
|---|---|---|---|---|
| `remember {}` | ✓ | ✗ | ✗ | Composition lifetime |
| `rememberSaveable {}` | ✓ | ✓ | ✓ (Bundle types only) | Composition + SavedState |
| `ViewModel` | ✓ | ✓ | ✗ (unless SavedStateHandle) | Nav back-stack entry or nav graph |
| `ViewModel` + `SavedStateHandle` | ✓ | ✓ | ✓ | Nav back-stack entry or nav graph |
**Recomposition** — Compose re-executes the composable body when state changes. `remember`
keeps the value alive across re-executions of the same composable instance.
**Config change** — Android destroys and recreates the Activity (screen rotation, locale
change, font size change). `remember` is lost; `rememberSaveable` and `ViewModel` survive.
**Process death** — Android kills the process entirely (low memory). Only `rememberSaveable`
(via `Bundle`/`SavedState`) and `ViewModel` with `SavedStateHandle` survive.
---
## The Decision Tree
```
Does this state involve async operations, IO, or repository calls?
├── YES → ViewModel
└── NO
├── Does this state need to survive screen rotation or config change?
│ ├── YES
│ │ ├── Is the type Bundle-safe (primitives, String, Parcelable, Serializable)?
│ │ │ ├── YES → rememberSaveable {}
│ │ │ └── NO → rememberSaveable(stateSaver = customSaver) { }
│ │ │ or ViewModel (if logic justifies it)
│ │ └── Is this state shared with another screen/route?
│ │ ├── YES → ViewModel (scoped to nav graph)
│ │ └── NO → rememberSaveable {}
│ └── NO → remember {}
└── Is this state shared between sibling composables?
├── YES → hoist to parent (see kmp-compose-state-hoisting)
└── NO → remember {} in the composable that owns it
```
---
## `remember {}` — In-Composition Memory
`remember` keeps a value alive for the **lifetime of the composable** in the composition.
When the composable leaves the tree and re-enters (back navigation, conditional rendering),
`remember` starts fresh.
```kotlin
// ✓ Correct uses of remember
@Composable
fun DropdownMenu() {
var expanded by remember { mutableStateOf(false) } // ephemeral UI toggle
// ...
}
@Composable
fun AnimatedComponent() {
val animatable = remember { Animatable(0f) } // animation object
// ...
}
@Composable
fun SearchBar(onSearch: (String) -> Unit) {
var query by remember { mutableStateOf("") } // local input before submit
// ...
}
```
```kotlin
// ❌ Wrong — remember loses this on rotation; user's half-typed form is gone
@Composable
fun RegistrationForm() {
var email by remember { mutableStateOf("") } // lost on config change!
var name by remember { mutableStateOf("") } // lost on config change!
// ...
}
```
### `remember` with keys
When a remembered value depends on inputs, use keys. The value is recomputed when any
key changes:
```kotlin
// Recomputed when userId changes — stale cache is discarded
val formatter = remember(userId) { UserFormatter(userId) }
// Recomputed when both locale and theme change
val painter = remember(locale, theme) { buildPainter(locale, theme) }
```
---
## `rememberSaveable {}` — Rotation-Proof Local State
Full content: `references/remembersaveable.md`.
## `ViewModel` — Config-Change-Proof Business State
Full content: `references/viewmodel.md`.
## ViewModel Scoping in Navigation Compose
By default, `koinViewModel()` / `viewModel()` scopes the ViewModel to the **current
back-stack entry**. When you navigate back, the ViewModel is cleared.
```kotlin
// Scoped to current back-stack entry — default, correct for most screens
@Composable
fun ProductDetailScreen(
viewModel: ProductDetailViewModel = koinViewModel(), // cleared when you pop back
) { ... }
```
To share a ViewModel across multiple destinations in a nested nav graph:
```kotlin
// Scoped to the nav graph — survives navigation between screens within the graph
@Composable
fun CheckoutScreen(navController: NavController) {
val backStackEntry = remember(navController) {
navController.getBackStackEntry("checkout_graph") // graph-level entry
}
val viewModel: CheckoutViewModel = koinViewModel(viewModelStoreOwner = backStackEntry)
// ...
}
```
This is the correct pattern for multi-step flows (checkout, onboarding) where all steps
share state — the ViewModel lives as long as the user is anywhere in the graph.
---
## `rememberCoroutineScope` — Composable-Scoped Coroutines
When you need to launch a coroutine from a composable (not from a ViewModel), use
`rememberCoroutineScope()`. The scope is cancelled when the composable leaves the tree.
```kotlin
@Composable
fun SaveButton(onSave: suspend () -> Unit) {
val scope = rememberCoroutineScope()
var isSaving by remember { mutableStateOf(false) }
AppButton(
onClick = {
scope.launch {
isSaving = true
onSave()
isSaving = false
}
},
enabled = !isSaving,
) {
if (isSaving) AppSpinner() else AppText("Save")
}
}
```
**Use `rememberCoroutineScope` when:**
- The coroutine must be tied to the composable lifecycle, not the ViewModel
- You need to launch a coroutine from a click handler (not from a `LaunchedEffect`)
- The coroutine triggers UI-only side effects (scroll, show keyboard, haptic)
**Do NOT use `rememberCoroutineScope` for:**
- API calls or repository access — those belong in `viewModelScope`
- State that needs to outlive the composable
---
## Quick Reference
```kotlin
// Ephemeral UI toggle — lost on rotation, that's fine
var isDropdownOpen by remember { mutableStateOf(false) }
// User input that must survive rotation — not worth a ViewModel
var searchQuery by rememberSaveable { mutableStateOf("") }
// Async data, business logic, or cross-screen state — ViewModel
val state by viewModel.state.collectAsStateWithLifecycle()
// Coroutine tied to composable lifecycle (scroll, animation, keyboard)
val scope = rememberCoroutineScope()
// Config-change AND process-death survival, with ID reference only
var selectedId by savedStateHandle.saveable { mutableStateOf<String?>(null) }
```
---
## `derivedStateOf` — Memoized Derived State
When a value is derived from other `State` but changes less frequently than its inputs,
wrap it in `derivedStateOf` so Compose only recomposes consumers when the derived value
actually changes.
```kotlin
// ❌ Recomputes canSubmit on every keystroke — recomposes Button even when result is stable
val canSubmit = email.isNotBlank() && password.length >= 8
// ✓ derivedStateOf — Button only recomposes when canSubmit flips true ↔ false
val canSubmit by remember(email, password) {
derivedStateOf { email.isNotBlank() && password.length >= 8 }
}
```
Always wrap `derivedStateOf` in `remember` — without it, a new `DerivedState` is created
each recomposition and the memoization is lost.
See `kmp-compose-state-hoisting` for full decision table and examples.
---
## `snapshotFlow` — Compose State as a Flow
`snapshotFlow` converts Compose `State` into a `Flow` so you can apply coroutine operators
(debounce, distinctUntilChanged, flatMapLatest) to Compose state changes.
```kotlin
// Debounce a search field — local Compose state → debounced coroutine → ViewModel intent
@Composable
fun SearchBar(onIntent: (SearchContract.Intent) -> Unit) {
var query by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
snapshotFlow { query }
.debounce(300)
.distinctUntilChanged()
.collect { q -> onIntent(SearchContract.Intent.Search(q)) }
}
AppTextField(value = query, onValueChange = { query = it })
}
```
| Use `snapshotFlow` when | Avoid it when |
|---|---|
| You need to debounce or throttle Compose state changes | The value already comes from a `StateFlow` — collect it normally |
| You need `distinctUntilChanged` on a Compose state | The transformation is pure — `derivedStateOf` is lighter |
| Bridging Compose state to analytics, accessibility, or an external SDK | |
---
## `rememberUpdatedState` — Latest Value Without Restarting an Effect
When a `LaunchedEffect(Unit)` captures a value or lambda that might change between
recompositions, wrap the captured value with `rememberUpdatedState`. The effect keeps a
stable reference that always points to the latest value — without restarting the coroutine.
```kotlin
// ✓ rememberUpdatedState — timer reads the latest onTick without restarting every recompose
@Composable
fun Ticker(onTick: () -> Unit) {
val currentOnTick by rememberUpdatedState(onTick)
LaunchedEffect(Unit) {
while (true) {
delay(1_000)
currentOnTick() // always the latest lambda
}
}
}
```
```kotlin
// ❌ Stale capture — onTick at launch time, not the latest
LaunchedEffect(Unit) {
while (true) { delay(1_000); onTick() } // stale if parent recomposes with new lambda
}
// ❌ Lambda as key — effect restarts (and timer resets) on every parent recompose
LaunchedEffect(onTick) {
while (true) { delay(1_000); onTick() }
}
```
**When to use:**
- `LaunchedEffect(Unit)` that reads a callback or config value that may change
- Any long-running effect that should NOT restart when a captured value changes
**When NOT needed:**
- Effect key already tracks the value (the effect will restart cleanly on key change)
- The captured value is `val` and provably never reassigned
---
## Common Mistakes
**1. `remember` for form data**
A registration form with `remember` loses all user input on screen rotation.
Use `rememberSaveable` for form fields, or a ViewModel if the form has validation logic.
**2. ViewModel for every bit of UI state**
`isDropdownOpen`, `isTooltipVisible`, `expandedCardIndex` — none of these belong in
a ViewModel. They're ephemeral, local, and have no business logic. `remember` is correct.
**3. `rememberSaveable` for large collections**
Bundle has a size limit. Saving a list of 500 products in `rememberSaveable` will crash
with a `TransactionTooLargeException` on Android. Save only IDs; reload data from cache/repo.
**4. Creating a new ViewModel scope per recomposition**
`viewModel()` / `koinViewModel()` must not be called inside a loop or conditional — it
creates a new ViewModel per call. Call it once at the screen level.
**5. Not understanding that Desktop/iOS don't have "config changes"**
`rememberSaveable` and `ViewModel` config-change behavior only applies on Android.
On Desktop and iOS, composable lifetime is tied to the window/view lifecycle. Plan your
state survival strategy accordingly if cross-platform survival matters.
---
## Verification
1. Rotate device (Android) — `rememberSaveable` values persist, `remember` values reset
2. Navigate away and back — back-stack-scoped ViewModel is cleared, new one created on return
3. Navigate between graph screens — graph-scoped ViewModel persists within the graph
4. Kill app from recents and relaunch — `savedStateHandle.saveable` values restored
5. `remember` state (dropdown open) resets when the composable re-enters the tree
---
## Testing
Full content: `references/testing.md`.
## Common Anti-Patterns
- using `derivedStateOf` without `remember` — memoization is lost; wrap every `derivedStateOf` in `remember`
- reading a `StateFlow` inside `snapshotFlow {}` — collect it with `collectAsState()` first; `snapshotFlow` only tracks Compose `State` objects
- storing domain objects in `remember` — they don't survive config change and belong in a ViewModel
- using `rememberSaveable` for large or non-parcelable types without a custom `Saver` — crashes at runtime
- scoping a ViewModel to the whole NavHost when it belongs to a nested graph — leaks state across features
- hoisting a `ViewModel` to a parent composable that doesn't need it — breaks the boundary between features
- using `remember` state to track loading/error — those are screen-state concerns and belong in the ViewModel
- capturing a changing lambda in `LaunchedEffect(Unit)` without `rememberUpdatedState` — the effect calls a stale closure; use `val current by rememberUpdatedState(lambda)` and call `current()` inside the loop
If state is disappearing on rotation, audit whether `rememberSaveable` or ViewModel is the right container.
---
## Related Skills
- `kmp-mvi` — ViewModel as the primary state container with `StateFlow` + `Channel<Effect>`
- `kmp-compose-state-hoisting` — when and where to hoist state vs keeping it in a container
- `kmp-presenter-module` — pure ViewModel pattern for containers with no Compose dependency
- `kmp-unit-testing` — testing ViewModel state transitions with Turbine
---
## Output Style
When asked about state containers or state survival, respond in this order:
1. recommendation (which container fits the survival requirement)
2. survival matrix row for the case (remember / rememberSaveable / ViewModel)
3. code snippet (smallest useful example)
4. why that choice is preferred
5. main alternative
Keep the survival matrix reference tight. Map to actual state names when the user provides them.
---
## Changelog
| Date | Change |
|---|---|
| 2026-08-04 | Split "rememberSaveable {}", "ViewModel", and "Testing" out of SKILL.md into `references/*.md`, leaving pointer stubs. SKILL.md drops from 589 to 437 lines, clearing the agentskills.io 500-line recommendation. No content removed, only relocated. Part of the same backlog cleanup as the other 16 skills fixed alongside it (KI-008). |
| 2026-06-28 | Add rememberUpdatedState section: stable reference to latest value in LaunchedEffect(Unit), when to use vs not, one new anti-pattern. |
| 2026-06-28 | Add derivedStateOf (memoized derived state, remember rule) and snapshotFlow (Compose State → Flow, debounce bridge) sections. Two new anti-patterns. |
| 2026-06-06 | Initial release. |