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 session
  • rememberSaveable — 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 rememberSaveable for Compose state — it automatically handles Bundle persistence across process death. Never use plain remember for 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 rememberSaveable for 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.