Until now the reading list was one screen. This post gives each book a screen of its own, with a note you can write, and with a second screen come the two questions every app eventually has to answer. Where am I? That is the back stack. What was I doing? That is the half-written note that has not been saved yet. On Android both are destroyed more often than people expect: a rotation recreates the Activity, and the system kills apps in the background whenever it needs memory. In a browser, a reload destroys everything that is not in the address bar or in storage.
The post builds both screens once, in commonMain, with Navigation 3 and ViewModels. Then it holds the unsaved note in five different ways, builds each into the app, and puts each through a rotation and process death on an Android 16 emulator, and through a reload, the back button and a direct link in a browser. Three of the five lose the note on Android, one of them by losing the whole screen. On the web, every one of them loses it on a reload.
1. Two Screens and One Back Stack
Navigation 3 treats the back stack as what it is: a list of screens that the app owns. Each screen is a route, a small serializable value:
@Serializable
sealed interface Route : NavKey
@Serializable
data object BookList : Route
@Serializable
data class BookDetail(val id: Int) : RouteOn Android, Navigation 3 can find a route’s serializer by reflection. Kotlin/Native and Kotlin/Wasm have no reflection, so in a multiplatform project the routes are registered by hand, once:
val routeConfig = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
subclass(BookList::class, BookList.serializer())
subclass(BookDetail::class, BookDetail.serializer())
}
}
}The screens are then a NavDisplay over the back stack, with one entry per route type:
val backStack = rememberNavBackStack(routeConfig, BookList)
NavDisplay(
backStack = backStack,
onBack = { navigator.back() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // rememberSaveable state, per screen
rememberViewModelStoreNavEntryDecorator(), // a ViewModel per screen, cleared when it is popped
),
entryProvider = entryProvider {
entry<BookList> { BookListScreen(viewModel { BookListViewModel(repo) }, …) }
entry<BookDetail> { route -> BookDetailScreen(viewModel { BookDetailViewModel(repo, route.id, createSavedStateHandle()) }, …) }
},
)rememberNavBackStack is itself a choice about state: the back stack is saved with the Activity’s saved state, so it comes back after a rotation or process death. Section 3 measures what happens when it is a plain remembered list instead.
2. Where State Can Live
A screen’s state can be held in five places, from shortest-lived to longest:
| Holder | Lives in | Cleared by |
|---|---|---|
remember | the composition | leaving the screen, a rotation |
| ViewModel field | the ViewModel, in memory | leaving the screen, process death |
rememberSaveable | the Activity’s saved state (a Bundle) | leaving the screen; on the web, a reload |
ViewModel’s SavedStateHandle | the ViewModel’s part of that saved state | leaving the screen; on the web, a reload |
| the database | a file | deleting it |
The app keeps its saved data in the database (Room, from the previous post) and its unsaved draft in the detail screen’s ViewModel, in a SavedStateHandle:
class BookDetailViewModel(private val repo: BookRepository, val id: Int, handle: SavedStateHandle) : ViewModel() {
val book: StateFlow<Load<Book?>> = repo.book(id).map { Load.Done(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Load.Loading)
/** The note being edited and not yet saved; null while it is unchanged. */
val draft: MutableStateFlow<String?> = handle.getMutableStateFlow("draft", null)
fun save(notes: String) { viewModelScope.launch { repo.setNotes(id, notes) } }
}These are the same androidx.lifecycle classes Android apps use, published for all four targets by JetBrains. One Android detail follows from them. A ViewModel outlives its Activity, so whatever it holds must too. The Android entry point used to open the database in onCreate, which after a rotation would have opened a second database under the ViewModels still using the first. It now opens one per process.
3. What Survives What, Measured
The experiment builds the app five times, changing only where the note is held: remember, rememberSaveable, a plain ViewModel field, the SavedStateHandle (the app as written), and, as a fifth variant, the SavedStateHandle again but with the back stack in a plain remember list.
On an Android 16 emulator, driven through adb, each test starts from a fresh install, opens Kotlin in Action, types “half a thought” into the note, closes the keyboard, and then does one thing:
- rotate the device, which destroys the Activity and creates a new one in the same process;
- move the app to the background and kill its process, as Android does to background apps when it needs memory (the script checks that the process is gone), then reopen it from the launcher;
- press back, then open the same book again.
In headless Chrome, each test starts in a fresh profile: reload the page, press the browser’s back and then forward buttons, and open a link straight to a book.
| The note in… | Rotate | Process killed | Browser reload |
|---|---|---|---|
remember | lost | lost | lost |
| ViewModel field | kept | lost | lost |
rememberSaveable | kept | kept | lost |
SavedStateHandle (the app) | kept | kept | lost |
SavedStateHandle, back stack in remember | lost: back on the list | lost: back on the list | lost |
Four results are worth spelling out.
-
A ViewModel is not saved state. It survives a rotation because the process survives. When Android kills the process in the background, the ViewModel goes with it, and the note was lost although the user had seen it on screen and never closed the app. Only
rememberSaveableandSavedStateHandlebrought it back, because Android keeps an app’s saved state outside its process. -
The back stack is state too. With the note held correctly but the back stack in a plain
remember, a rotation put the user back on the list. The note had been restored, for a screen that was no longer there. -
Leaving a screen discards its state, in every variant. After back and reopening the book, the note was empty for all five holders. Navigation 3 clears a screen’s ViewModel and its saved state when the screen is popped, which is the right default (a screen that is gone should not hold memory), but it means an unsaved note is lost by a single back press. The app shows “Unsaved changes” next to the Save button; a note that must never be lost belongs in the database as the user types, not in UI state.
-
On the web, nothing short of storage survives a reload. All five variants lost the note on a reload,
rememberSaveableandSavedStateHandleincluded: a browser has no saved instance state, so the page starts again from nothing. What survived was the screen, because the app keeps it in the address bar (next section).
Desktop was not tested: a desktop app has no rotation and is not killed in the background, and closing the window ends the process, so there only the database survives.
4. The Browser Owns History
Navigation 3 does not integrate with the browser’s history yet. JetBrains points to a proof-of-concept library and plans support for a later release. Without it, the browser’s back button leaves the site, a reload returns to the first screen, and no screen has a link.
The app does it itself, in 50 lines (36 in commonMain, 14 of JavaScript interop), and the design follows the previous posts. In commonMain, the browser’s history is a capability that only the web has (BrowserHistory?, null elsewhere). A route has an address, #/book/2. And a small Navigator decides who owns the truth:
class Navigator(private val backStack: MutableList<NavKey>, private val history: BrowserHistory?) {
fun open(route: Route) {
backStack.add(route)
history?.push(route) // the address bar shows #/book/2
}
fun back() {
if (history != null) history.back() // ask the browser; it answers with popstate
else if (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
}
fun follow(route: Route?) { // popstate: back, forward, or the app's own back
val target = stackFor(route) // a book always has the list under it
if (backStack.toList() != target) { backStack.clear(); backStack.addAll(target) }
}
}On Android, desktop and iOS there is no history to agree with, so back() pops the stack. In the browser, history is the truth: going back asks the browser, and the back stack is rebuilt from whatever address the browser then reports, so the app’s back button, the browser’s buttons and the address bar can never disagree. The web side is a few lines of JavaScript interop around history.pushState, history.back() and the popstate event, and the routing logic is in commonMain, where four tests cover it on every target.
In the browser test, a reload kept the book’s screen (the address was still #/book/2), back returned to the list (the address became empty), forward returned to the book, and a link to #/book/3 opened that book (with the list beneath it, which stackFor puts there and a test checks).
5. What the Links Found
Opening a link straight to a book found two bugs that clicking through the app never would.
The start-up work was in the wrong place. The database’s first-start work (moving version 2’s data, or adding the sample books) ran in the list screen’s ViewModel, because the list had always been the first screen. A link to #/book/3 in a fresh browser shows only the book’s screen. The list’s ViewModel was never created, the sample books were never added, and the screen said “Loading…” for good. The start-up now runs in an AppViewModel above both screens, whatever screen the app opens on.
“Not loaded yet” and “not there” looked the same. The detail screen’s book was a StateFlow<Book?> starting at null, so a link to a book that does not exist, #/book/99, also said “Loading…” for good. It is now a Load<Book?>, either Loading or Done(value), and Done(null) says “This book is not in the list.” The browser test checks both.
6. A Second Schema Version
Notes need a column. Book gains notes, and the database goes from version 1 to version 2 with a migration that Room writes itself, from the two schemas it exported:
@Entity(…)
data class Book(
…,
@ColumnInfo(defaultValue = "") val notes: String = "", // new in schema version 2
)
@Database(entities = [Book::class, Setting::class], version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)])
abstract class BookDatabase : RoomDatabase() { … }The defaultValue matters: it is what lets SQLite add a NOT NULL column to rows that already exist. A migration test on the JVM creates a version-1 database with Room’s MigrationTestHelper, writes a book into it the way version 3 of the app would have, migrates, validates the result against 2.json, and checks that the book and the “initialized” marker came through unchanged.
In the browser, the same upgrade was tested on real stored data: version 3 of the app was loaded and edited, the files on the server were replaced by version 4 at the same address, and after a reload the edited list was there, in schema version 2, from the OPFS database version 3 had written.
7. The Reading List, Version 4
Open a book, write a note, and reload: the book’s screen comes back, the unsaved note does not. Save it, and it does. The browser’s back and forward buttons move between the list and the book. (The page embeds the chapter’s latest version of the app, which since the next post shows the list and the book side by side in a wide enough window; the code and sizes here are those of version 4, in the download below.)
| Target | Lines it compiles | Of which in commonMain |
|---|---|---|
Android (with androidApp) | 313 | 86.9% |
| iOS | 294 | 92.5% |
| desktop | 299 | 91.0% |
| web | 330 | 82.4% |
commonMain doubled, from 135 to 272 lines: the second screen, the routes, the navigator and the ViewModels. The platforms barely changed; the web added the history interop. The shares are the highest of the chapter so far, because what was added is the kind of code that does not depend on a platform.
This time an emulator was available, so commonTest also ran on Android, as instrumented tests: the twelve tests (repository, navigation, timing) pass on Android 16 with AndroidSQLiteDriver, as they do on the desktop JVM and in Chrome, plus the migration test on the JVM. On that emulator, the timing test from the previous post took 137–169 ms for 200 single inserts and 9–10 ms for 1,000 in one transaction, over three runs. That is between the desktop JVM and the browser, but it is an emulator on a Mac, not a phone.
The new libraries (Navigation 3, the lifecycle and saved-state libraries) cost 0.12 MB in the Android APK (1.29 to 1.42 MB) and 0.11 MB in the compressed web download (5.06 to 5.17 MB).
To build it, download the project (reading-list-4.zip, 81 KB), unzip it, and with JDK 21 and the Android SDK:
cd reading-list-4
./gradlew :shared:jvmTest # shared tests and the migration test, desktop JVM
CHROME_BIN="/path/to/chrome" ./gradlew :shared:wasmJsBrowserTest # … in Chrome
ANDROID_SERIAL=emulator-5554 ./gradlew :shared:connectedAndroidDeviceTest # … on a device or emulator
./gradlew :androidApp:assembleRelease :shared:run :shared:wasmJsBrowserDistribution8. Common Claims, Checked
| Claim | Verdict |
|---|---|
| ”Put state in a ViewModel and it survives.” | It survives a rotation. When Android killed the process in the background, a ViewModel field lost the note; SavedStateHandle and rememberSaveable kept it. |
”rememberSaveable saves state.” | On Android, across a rotation and process death, measured. In a browser it lost the note on a reload, like every other holder. |
| ”The back stack takes care of itself.” | Held in a plain remember, it put the user back on the list after a rotation. rememberNavBackStack kept the screen. |
| ”Navigation 3 handles the browser’s back button.” | Not yet: browser history is a separate proof-of-concept today. The app syncs the address bar itself with pushState and popstate. |
| ”Navigation 3 works the same on every platform.” | The API does, but outside the JVM it cannot find route serializers by reflection; they are registered in a SavedStateConfiguration. |
| ”A screen keeps its state while you are away from it.” | When it is popped, Navigation 3 clears its ViewModel and saved state. A single back press discarded the unsaved note in all five variants. |
| ”If the app works when you click through it, it works.” | A direct link found start-up work living in the first screen’s ViewModel, and a missing book shown as loading forever. |
| ”Shared tests cannot run on Android without extra work.” | With withDeviceTestBuilder { sourceSetTreeName = "test" }, commonTest ran unchanged on an Android 16 emulator, with Android’s SQLite. |
Summary
| Idea | In one line |
|---|---|
| Navigation 3 | The back stack is a list of serializable routes the app owns; NavDisplay shows its top |
| Holders | remember < ViewModel < saved state (rememberSaveable, SavedStateHandle) < the database |
| Measured | ViewModel fields die with the process; saved state does not; on the web, only storage and the URL survive a reload |
| The web | Browser history is the truth; the back stack follows popstate; routes have addresses |
| Links | A link straight to a screen is the test that finds start-up work in the wrong place |
| Schema | Version 2 adds notes; Room’s auto-migration, tested on the JVM and in the browser on stored data |
Next: One UI, Many Windows: Adaptive Layouts, Resources and Fonts in Compose Multiplatform. The user interface itself. All four targets currently draw a phone layout. The next post makes Compose Multiplatform’s UI adapt (a list beside its detail on a wide window, one at a time on a phone), adds shared resources and strings, and measures what changes between platforms that render with Skia and Android, which does not.
The app: reading-list-4 (zip) (Kotlin 2.4.21, Compose Multiplatform 1.12.2, Navigation 3 1.1.2, lifecycle 2.11.0, Room 3.0.3, AGP 9.4.1, Gradle 9.8.1). The experiment: state_survival.py, which builds the five variants and drives an Android emulator through adb and Chrome through puppeteer. Everything ran on this machine (macOS, Apple silicon, JDK 21, Chrome, an Android 16 emulator with the Google APIs arm64 image); the iOS code was compiled but not linked or run, since there is no Xcode here.
References
Navigation and state
- JetBrains. Navigation 3 in Compose Multiplatform:
SavedStateConfiguration, and the status of browser history. - Android Developers. Navigation 3: the back stack,
NavDisplayand entry decorators. - Android Developers. Save UI states and Saved State module for ViewModel.
- Android Developers. Processes and app lifecycle: when Android kills background processes.
- JetBrains. Common ViewModel in Compose Multiplatform.
The browser and testing
- MDN. History API and the popstate event.
- Android Developers. Room migrations and Test migrations.
- Android Developers. Set up the Android Gradle library plugin for KMP: host and device tests.