Preserve Item State in Jetpack Compose LazyColumn with Stable Keys
Learn how to keep UI state (e.g., expanded/collapsed) of list items stable while scrolling and across configuration changes using LazyColumn item keys and rememberSaveable.
16 Oct 2025, 18:37 UTC

Desired outcome
When a user scrolls a list built with LazyColumn, any UI state inside each item (for example, an expanded panel or a selected checkbox) should remain unchanged when the item scrolls off‑screen and back, and it should survive device rotation or process recreation.
Prerequisites
- Android Studio Flamingo or later (any recent version that supports Compose).
- Kotlin 1.8.0 or higher.
- A project configured to use Jetpack Compose (minimumComposeVersion set).
- Access to a device or emulator with USB debugging enabled (for running the app).
No special permissions are required for the build or runtime; only the usual android.permission.INTERNET if your app accesses the network.
Procedure
1. Add the Compose BOM and dependencies
Open the module‑level build.gradle.kts (or build.gradle) and insert the Compose Bill of Materials (BOM) with a stable version, then add the UI and Material3 libraries.
// build.gradle.kts (module: app)
val composeBom = platform('androidx.compose:compose-bom:2024.09.00')
dependencies {
implementation(composeBom)
implementation('androidx.compose.ui:ui')
implementation('androidx.compose.material3:material3')
implementation('androidx.compose.ui:ui-tooling-preview')
debugImplementation('androidx.compose.ui:ui-tooling')
}
Sync the project. Using the BOM ensures all Compose libraries share the same version, reducing the chance of API mismatches.
2. Define a data class with a stable identifier
Each list item needs a unique, unchanging ID that Compose can use as a key.
data class ListItem(
val id: String, // stable unique key (e.g., UUID or server‑generated)
val title: String,
var isExpanded: Boolean = false // UI state we want to preserve
)
The id must not change during the item’s lifetime; if it does, Compose may associate the wrong saved state with the item.
3. Build the LazyColumn with item keys
Use the overload of items that accepts a key lambda. Pass the id as the key.
@Composable
fun ItemList(items: List) {
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item)
}
}
}
@Composable
fun ItemRow(item: ListItem) {
var expanded by rememberSaveable { mutableStateOf(item.isExpanded) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(MaterialTheme.cardColors().surface, shape = RectangleShape)
) {
Text(text = item.title, style = MaterialTheme.typography.titleMedium)
if (expanded) {
Text(text = "Details for ${item.id}")
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { expanded = !expanded },
enabled = true
) {
Text(if (expanded) "Collapse" else "Expand")
}
}
}
rememberSaveable wraps the mutable state so it survives recompositions, configuration changes, and process death, as long as the type stored (Boolean) is parcelable (which it is). For non‑parcelable types you would need a custom Saver.
4. Host the LazyColumn in an Activity
In your MainActivity (or any Compose‑based Activity), set the content to a Scaffold containing the list.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Scaffold {
ItemList(generateSampleList())
}
}
}
}
private fun generateSampleList(): List {
return (1..30).map { ListItem(id = "item_$it", title = "Item $it") }
}
}
Expected checks
- Run the app on an emulator or device (
./gradlew installDebugthen launch). - Scroll down the list, expand several items (e.g., items 3, 7, 12).
- Continue scrolling so those items move off‑screen, then scroll back up.
- Verify that the previously expanded items remain expanded.
- Rotate the device (or emulator) to trigger a configuration change; the expanded state should persist.
- Open Android Studio’s Layout Inspector, select a list item, and confirm that the
rememberSaveablestate is attached to the item’s node and does not change when the item is re‑composed.
Recovery options
If state is not preserved:
- Duplicate or changing keys: Ensure each item’s
idis unique and immutable. Log the IDs in the composable to verify. - Incorrect use of
rememberSaveable: Confirm that the type you save is parcelable or provide a customSaver. For complex data classes, implementParcelableor userememberSaveable(stateSaver = customSaver). - Unstable Compose version: Switch to the latest stable BOM (as shown) or a known good release; avoid canary/dev builds for production code.
- State hoisted too high: If you hoist state above the
LazyColumn, it will not be tied to individual item keys. Keep state inside the item lambda as shown.
Limitations and practical verification
RememberSaveable works out‑of‑the‑box for primitive types, String, Parcelable, and certain Compose types. For custom objects that are not parcelable, you must define a Saver:
val customSaver = run {
val saveListItem: (ListItem) -> Any = { it.id }
val restoreListItem: (Any) -> ListItem = { id -> ListItem(id = id.toString(), title = "") }
Saver(saveListItem, restoreListItem)
}
rememberSaveable(stateSaver = customSaver) { ... }
To verify that your custom saver works, repeat the scroll‑and‑rotate test and confirm the restored object retains the expected fields.
Finally, keep the Compose library up to date but test each upgrade on a representative device, as occasional API tweaks can affect rememberSaveable behavior.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.