The Problem: Android Process Death
When I was building EmpSuite, our team encountered a frustrating bug: users would be filling out a complex form, the system would kill the app in the background to free memory, and when they returned—the entire form was blank. No error. No crash. Just data loss.
This is Android process death, and it's one of the most overlooked challenges in Jetpack Compose development. Unlike older Fragment-based Android development where you had lifecycle callbacks and automatic state saving, Jetpack Compose puts the burden of state persistence on you. Miss this, and your users will lose data.
In this post, I'll share the exact patterns I've used across production apps to handle state restoration in Jetpack Compose so your Android architecture remains resilient even when the OS terminates your process.
"Process death happens. Users don't care about your excuses. They care about their data. Build for it."
Understanding rememberSaveable in Jetpack Compose
The first tool in your arsenal is rememberSaveable. This is Jetpack Compose's answer to state restoration after process death.
The key difference from remember:
remember— survives recomposition within the same sessionrememberSaveable— survives recomposition AND process death (via Bundle persistence)
When you use rememberSaveable, Compose automatically saves your state to Android's Bundle during onSaveInstanceState and restores it when the activity is recreated.
// ❌ Bad: Lost on process death
var email by remember { mutableStateOf("") }
// ✅ Good: Survives process death
var email by rememberSaveable { mutableStateOf("") }
// ✅ Better: With key for debugging
var email by rememberSaveable(key = "email_input") { mutableStateOf("") }
@Composable
fun LoginForm() {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") }
)
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation()
)
Button(onClick = { /* login */ }) {
Text("Sign In")
}
}
}📖 How It Works
rememberSaveable uses a Saver object behind the scenes. For primitives like String, Int, Boolean, Compose provides default savers. For complex objects, you need to define your own (more on that later).
Implementing State Restoration in MVVM Android Architecture
In real production apps, you're not just storing simple strings. You're managing complex MVVM Android patterns with ViewModels, Repositories, and domain models.
Here's how I structure state restoration across the layers:
Layer 1: ViewModel State
Your ViewModel should own the source of truth. Use SavedStateHandle to automatically persist ViewModel state:
@HiltViewModel
class FormViewModel @Inject constructor(
private val repository: FormRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _formState = MutableStateFlow(
savedStateHandle.get<FormState>("formState")
?: FormState()
)
val formState: StateFlow<FormState> = _formState.asStateFlow()
fun updateEmail(email: String) {
val current = _formState.value
_formState.value = current.copy(email = email)
// Auto-save to SavedStateHandle
savedStateHandle["formState"] = _formState.value
}
}
data class FormState(
val email: String = "",
val name: String = "",
val phone: String = "",
val isLoading: Boolean = false
)Layer 2: Composable State with rememberSaveable
Even when your ViewModel handles persistence, your Composable UI should have a local backup using rememberSaveable:
@Composable
fun FormScreen(viewModel: FormViewModel = hiltViewModel()) {
val formState by viewModel.formState.collectAsState()
// Local UI state with process death resilience
var emailInput by rememberSaveable { mutableStateOf("") }
var nameInput by rememberSaveable { mutableStateOf("") }
// Sync from ViewModel when first loaded
LaunchedEffect(formState) {
emailInput = formState.email
nameInput = formState.name
}
Column(modifier = Modifier
.fillMaxSize()
.padding(16.dp)) {
TextField(
value = emailInput,
onValueChange = { newEmail ->
emailInput = newEmail
viewModel.updateEmail(newEmail)
},
label = { Text("Email") }
)
TextField(
value = nameInput,
onValueChange = { newName ->
nameInput = newName
viewModel.updateName(newName)
},
label = { Text("Name") }
)
Button(
onClick = { viewModel.submitForm() },
modifier = Modifier
.align(Alignment.End)
.padding(top = 16.dp)
) {
Text("Submit")
}
}
}⚠️ Sync Carefully
Don't create circular state flows between ViewModel and Composable. The pattern above uses LaunchedEffect to sync once when loaded, then the Composable owns the input state locally.
Custom Savers for Complex Objects
Jetpack Compose can only serialize primitives and Lists by default. When you have complex domain objects, you need a custom Saver.
I ran into this building AudioBook AI with complex book metadata. Here's how I solved it:
data class Book(
val id: String,
val title: String,
val author: String,
val chapters: List<Chapter>,
val currentPosition: Long
)
data class Chapter(
val number: Int,
val title: String,
val duration: Long
)
// Define a custom Saver
val BookSaver: Saver<Book, Map<String, Any>> = mapSaver(
save = { book ->
mapOf(
"id" to book.id,
"title" to book.title,
"author" to book.author,
"currentPosition" to book.currentPosition,
"chaptersJson" to Json.encodeToString(book.chapters)
)
},
restore = { map ->
Book(
id = map["id"] as String,
title = map["title"] as String,
author = map["author"] as String,
chapters = Json.decodeFromString(map["chaptersJson"] as String),
currentPosition = map["currentPosition"] as Long
)
}
)
// Use it with rememberSaveable
@Composable
fun AudiobookPlayer(bookId: String) {
var currentBook by rememberSaveable(saver = BookSaver) {
mutableStateOf(Book.empty())
}
// Your UI here
}The key insight: convert complex objects to Maps or JSON strings, then restore them. It's not elegant, but it's reliable.
📖 When to Use Custom Savers
Use them only when necessary. For most cases, keep Compose state simple (Strings, Ints, Booleans) and store complex objects in your ViewModel or Repository.
Testing State Restoration in Jetpack Compose
Theory is one thing. Testing is another. I always add these tests to verify state survives process death:
@RunWith(AndroidJUnit4::class)
class FormStateRestorationTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<FormActivity>()
@Test
fun testEmailStateSurvivedProcessDeath() {
// Enter email
composeTestRule.onNodeWithTag("email_field")
.performTextInput("user@example.com")
// Verify it's there
composeTestRule.onNodeWithTag("email_field")
.assert(hasText("user@example.com"))
// Simulate process death + recreation
composeTestRule.activityRule.scenario.recreate()
// Verify email is restored
composeTestRule.onNodeWithTag("email_field")
.assert(hasText("user@example.com"))
}
@Test
fun testFormStateRestoredFromViewModel() {
// Enter data
composeTestRule.onNodeWithTag("name_field")
.performTextInput("John Doe")
composeTestRule.onNodeWithTag("phone_field")
.performTextInput("+1234567890")
// Trigger save
composeTestRule.onNodeWithTag("submit_btn").performClick()
// Recreate
composeTestRule.activityRule.scenario.recreate()
// Verify restoration
composeTestRule.onNodeWithTag("name_field")
.assert(hasText("John Doe"))
composeTestRule.onNodeWithTag("phone_field")
.assert(hasText("+1234567890"))
}
}In my experience, this test catches state restoration bugs before users do. Run it every time you refactor state handling.
Key Takeaways
- Use
rememberSaveablefor Compose state — it automatically handles Bundle persistence across process death. Never use plainrememberfor user input or critical app state. - Layer your state: ViewModel + Composable — keep the source of truth in ViewModel (via SavedStateHandle), but maintain local UI state in Composable with
rememberSaveablefor resilience. - Custom Savers for complex objects — convert domain models to Maps or JSON before saving. It's boilerplate, but it beats losing user data in production.
- Test state restoration with
recreate()— don't assume it works. Simulate process death in your tests to catch bugs before release. - Keep Compose state lightweight — avoid storing large objects or coroutine state directly. Delegate to ViewModels and Repositories for heavy lifting.
State restoration isn't glamorous. It doesn't make your app feel faster or look prettier. But it makes your app feel trustworthy, and that's what separates apps users love from ones they uninstall.