Saving State in Jetpack Compose: From Recomposition to Process Recreation
The most common state bug in a Compose app is rarely a lack of StateFlow knowledge. It is usually the result of putting data with different lifetimes into the same container. A board resets after rotation, a filter disappears when the user returns to a list, or process recreation restores half of an old game because the design never answered the first question: how long should this state live?
Consider a fully offline puzzle game. Its UI contains a selected card, an in-progress board, a high score, and match history. All four are “state,” but they do not share a lifetime.
| State | Appropriate owner | Boundary it crosses |
|---|---|---|
| Press state or expanded panel | remember | Recomposition |
| Text input, scroll position, selected tab | rememberSaveable | Activity and system-initiated process recreation |
| Screen business state | ViewModel | Recomposition and configuration changes |
| Small keys needed to rebuild business state | SavedStateHandle | System-initiated process recreation |
| Theme, difficulty, sound preference | DataStore | App restarts |
| History, puzzle library, statistics | Room | App restarts, queries, and schema migrations |
This is not merely a framework selection table. It is an ownership table. Decide who owns the data before choosing the API.
remember belongs to the current composition
remember preserves a value across recompositions, but the value can disappear when its composable leaves the composition. It fits a purely visual detail such as whether a help card is expanded:
@Composable
fun RulesCard() {
var expanded by remember { mutableStateOf(false) }
RulesContent(
expanded = expanded,
onToggle = { expanded = !expanded },
)
}
A high score, game progress, or user preference does not belong in remember. Those values outlive one composition, and the UI should not be their only source of truth.
rememberSaveable stores a reconstruction hint
In addition to surviving recomposition, rememberSaveable uses saved instance state to handle Activity recreation and system-initiated process death. It is a good fit for a small amount of UI state that can be represented in a Bundle:
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
“Small” matters. Do not serialize a full puzzle library, large image, long list, or repository object into saved state. Save a puzzle ID, tab number, or short unfinished input. Use that key to load substantial data again from the data layer.
When state participates in business logic and is owned by a ViewModel, put the reconstruction parameter in SavedStateHandle:
class GameViewModel(
private val repository: GameRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val puzzleId = savedStateHandle.getStateFlow("puzzle_id", "daily")
val uiState: StateFlow<GameUiState> = puzzleId
.flatMapLatest(repository::observeGame)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = GameUiState.Loading,
)
fun openPuzzle(id: String) {
savedStateHandle["puzzle_id"] = id
}
}
The saved value is not the complete game. It is the puzzle_id needed to find the game again. That distinction is useful in practice: saved state records how to reconstruct; persistent storage contains what to reconstruct.
A ViewModel produces screen state; it is not a database
Android’s architecture guidance recommends that a ViewModel expose UI state and receive user actions through methods. Compose consumes immutable state and reports events, keeping data movement unidirectional:
data class PlayingState(
val cards: List<Int>,
val selected: Set<Int>,
val score: Int,
val isChecking: Boolean,
)
@Composable
fun GameRoute(viewModel: GameViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
GameScreen(
state = state,
onCardClick = viewModel::selectCard,
onSubmit = viewModel::submit,
)
}
GameScreen does not need to know about a database, Context, or a coroutine scope. A preview can receive fixed state. A test can call a ViewModel action and assert the next state.
A ViewModel is still a screen-level state holder. It disappears when the process truly ends. Data that must survive an app restart belongs in the data layer.
DataStore and Room solve different problems
DataStore is appropriate for a small set of settings. Difficulty, theme, vibration, and sound can live in Preferences DataStore; Proto DataStore is available when the data benefits from a typed schema. Android’s current guidance also recommends that projects using SharedPreferences consider migrating to DataStore.
Room is appropriate for structured, queryable data such as match history, daily puzzles, score statistics, and favorites. It verifies SQL queries at compile time and provides an explicit schema migration path. Once a feature needs “the highest score in the last 30 days,” history filtered by difficulty, or relationships between records, storing serialized JSON in DataStore is usually creating a future migration problem.
The data layer can give the UI one stable entry point:
interface GameRepository {
fun observeGame(id: String): Flow<GameUiState>
suspend fun saveMove(gameId: String, move: Move)
suspend fun finishGame(gameId: String, score: Int)
}
The UI does not access a DAO or DataStore directly. The repository chooses where data comes from, when it reaches disk, and how persistence models become screen state. That boundary remains useful even if the app will never use a network. Offline-first does not mean “there is no networking code”; it means local data has a clear single source of truth.
Persistence timing causes more bugs than persistence technology
Writing every move synchronously is safe but may create unnecessary I/O. Saving only in onStop looks efficient but can lose progress after an abnormal termination. A stronger policy classifies writes by the value of the data:
- Commit irreplaceable results such as high scores and completed matches when the business action succeeds.
- Save an in-progress board after each valid move; serialize writes or use a short debounce to combine bursts of interaction.
- Do not persist purely visual details such as animation progress and press state.
- Make write failure observable instead of swallowing it in a log.
The data layer should also own its threading policy. A caller should not have to guess whether a repository method is safe to invoke from the main thread.
Test the restoration boundaries
A test that proves text changes after a tap is not enough. Cover at least these transitions:
- temporary UI state behaves as expected after recomposition;
- input and the current board survive rotation or window-size changes;
- returning to the screen works with “Don’t keep activities” enabled;
- after system process recreation, small saved keys reload persistent data;
- after a force stop and relaunch, preferences and completed results remain;
- real data from the previous schema migrates during an upgrade.
Restoration tests also expose a common mistake: keeping the same mutable value in a composable, a ViewModel, and a database. Those three copies restore at different times, causing flicker, rollback, or stale data overwriting newer data.
A migration path that does not require a rewrite
An existing app can improve one boundary at a time:
- Combine everything needed to render a screen into an immutable
UiState. - Make composables accept only state and event callbacks.
- Move screen-level business state into a
ViewModel. - Put only small reconstruction keys and filters in saved state.
- Migrate durable settings from
SharedPreferencesto DataStore. - Move queryable history and progress into Room, with migration tests.
The measure of success is not how many Jetpack components the project uses. It is whether every piece of state has exactly one authoritative owner. remember, ViewModel, saved state, DataStore, and Room cross different lifetime boundaries. Once those boundaries are explicit, a Compose app can still feel like the same app after recomposition, rotation, process death, and an offline restart.