A store held in a singleton or a DI graph survives rotation for free. The process lives, so the store lives, and the new Activity reads the same state the old one did. Rotation was never the hard part.
Process death is the hard part. Android kills your backgrounded app, the user comes back, and the store is rebuilt from initial state. They are looking at the login screen holding a phone they were using ninety seconds ago.
Why you can’t just fix this in the composable
The obvious move is rememberSaveable on whatever the screen needs, and it doesn’t work.
The bindings are one-directional: the store is the source of truth and the composable reads from it. If you restore a value into a composable, the store still holds its initial state, and the next time that subscription fires it will overwrite what you restored. You haven’t restored state; you’ve put a sticky note on top of it.
The only way state gets into the store is a dispatch. So the restore has to be a dispatch. That’s the single design decision behind this API, and everything else follows from it:
public class StateSaver<S, Snapshot : Any>( public val serializer: KSerializer<Snapshot>, // Snapshot must be @Serializable public val save: (S) -> Snapshot, // project state -> a minimal snapshot public val restore: (Snapshot) -> Any, // decoded snapshot -> an ACTION public val json: Json = Json,)restore returns an action, not a state. That’s the point, not an implementation detail leaking out.
The whole thing, end to end
implementation("org.reduxkotlin:redux-kotlin-compose-saveable:1.0.0-alpha04")data class UiState(val tab: Int = 0, val query: String = "")
data class SetTab(val tab: Int)data class RehydrateUi(val tab: Int, val query: String) // the restore action
@Serializabledata class UiSnapshot(val tab: Int, val query: String) // what actually gets persisted
val reducer: Reducer<UiState> = { state, action -> when (action) { is SetTab -> state.copy(tab = action.tab) is RehydrateUi -> state.copy(tab = action.tab, query = action.query) else -> state }}
val uiSaver: StateSaver<UiState, UiSnapshot> = StateSaver( serializer = UiSnapshot.serializer(), save = { state -> UiSnapshot(state.tab, state.query) }, restore = { snap -> RehydrateUi(snap.tab, snap.query) },)Then anchor it once, in a composable that lives for as long as the state should:
@Composablefun App(store: Store<UiState>) { store.rememberSaveableState(uiSaver) // ...}Four lines of wiring and the app survives process death.
How it works, and why the details matter
It rides SaveableStateRegistry, the exact machinery behind rememberSaveable. On Android that registry is wired to savedInstanceState, which means one mechanism covers rotation and process death at once. You don’t write two code paths.
The restore is applied synchronously during composition, inside a remember, not a DisposableEffect. So the very first frame is already rehydrated. No flash of initial state, no one-frame login screen.
The save side costs nothing at steady state. There’s no subscription. The provider serializes only at the moment the platform actually asks to save.
Encode and decode are both runCatching. A corrupt or version-skewed snapshot is dropped and the app starts cold. Shipping a new app version that changed the snapshot shape will never crash on a stale snapshot, which is the failure mode that makes people afraid of this whole category of feature.
Restore is exactly one dispatched action, flowing through your full middleware chain, consumed once so recomposition can’t double-apply it.
The platform caveat
Android’s SaveableStateRegistry is wired to the OS. iOS, desktop, JS, and wasm aren’t. The Compose runtime on those targets doesn’t connect the registry to an OS restore mechanism, so the anchor is effectively a no-op for process death there.
I would love to tell you this is a nine-platform feature. It’s an Android feature with an API that happens to be in commonMain. On the other platforms, persist what you need yourself and hand it to the store at construction:
rememberSaveableState | preloadedState | |
|---|---|---|
| Storage | OS saved-instance state | Yours: DB, files, server |
| Survives | Rotation + process death (Android) | Anything, including a cold start |
| Size | Small snapshots only | Whatever you can load |
| Restored at | First composition of the anchor | Store construction |
TaskFlow splits along exactly that line: boards and cards live in SQLDelight and come back via preloadedState, the nav stack and the current filter go in the StateSaver, and the half-typed text in a new-card field is plain rememberSaveable. Pick the mechanism by how long the data should outlive the screen.
Two things TaskFlow taught me
Snapshot a DTO, not your domain types. TaskFlow’s Route is a sealed class full of domain IDs, it’s not @Serializable, and I don’t want it to be. So there’s a small @Serializable RouteDto mirror with short @SerialNames, and the saver translates.
The nice consequence: because the DTO is a deliberate projection, you can choose what not to persist. RouteDto.CardDetail drops the mode field, so a card that was mid-edit when the process died always restores in View mode rather than dropping the user back into a half-finished edit they’ve forgotten the context for.

That’s a product decision, and it lives in one line of a data class.
Key the anchor when it’s not unique. TaskFlow has a store per account, anchored at the same call site:
accountStore.rememberSaveableState(accountUiSaver, key = "account-ui-${activeId.v}")Without the explicit key, the default composite key is identical across accounts and they overwrite each other. TaskFlow shipped that bug once.
The bug you hit after it works
This is the real content of the post, and it’s the thing nobody warns you about.
Restoration replays no events.
You get one action. You do not get the sequence of user actions that produced the state. So consider a screen that loads its data in response to navigation:
// this fires when the user navigates. It does NOT fire on restore.DisposableEffect(Unit) { store.dispatch(LoadBoard(boardId)); onDispose { } }After a restore, the nav stack says you’re on the board screen, the board screen renders, and it renders empty, forever, because the Navigate action that used to trigger the load never happened. The state was restored. The effect of getting there wasn’t.
The fix is to key your load effects on state, not on the event that produced it:
DisposableEffect(nav.activeBoardId) { nav.activeBoardId?.let { store.dispatch(LoadBoard(it)) } onDispose { }}Now it fires whether you arrived by tapping, by deep link, by restore, or by DevTools time-travel, because all four produce the same state and none of them produce the same events.
That’s the general form, and it’s worth internalizing: deep links, process-death restore, time travel, and action replay are all the same bug. Get effects keyed on state instead of on events and you fix all four at once. Leave one event-keyed and you will find it four separate times, in four separate bug reports, and never notice they were the same.
This is codified as a rule in the library’s agent-facing docs, for what I think is an obvious reason: it’s exactly the sort of thing that looks correct in review, passes every test that starts from a cold launch, and only fails for a real user on a real phone with real memory pressure. A coding agent will not catch it by staring harder. It gets caught by a rule that’s written down.
Full details in the Compose integration doc.
Happy restoring, and may your effects be keyed on state.
Sources of truth: reduxkotlin.org · github.com/reduxkotlin/redux-kotlin · the TaskFlow sample
Part 6 of ReduxKotlin 1.0.