Ever wondered why your Compose UI loses state during rotation? Or why some values reset while others persist? The answer lies in understanding remember vs rememberSaveable.
The Problem: State Lost on Configuration Change
When your phone rotates or the system kills your app in background, you want certain UI state to survive. But by default, Compose recomposes from scratch—and some state disappears!
Understanding the Difference
What remember Does
@Composable
fun CounterScreen() {
// ❌ PROBLEM: State lost on configuration change (rotation, dark mode toggle)
var count by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Count: $count",
style = MaterialTheme.typography.headlineMedium
)
Button(onClick = { count++ }) {
Text("Increment")
}
}
}- Stores value only during composition
- Survives recomposition (same composition)
- Lost on configuration change (rotation, locale change)
- Lost when process is killed
What rememberSaveable Does
@Composable
fun CounterScreenSaved() {
// ✅ SURVIVES: Configuration changes and process death
var count by rememberSaveable { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Count: $count",
style = MaterialTheme.typography.headlineMedium
)
Button(onClick = { count++ }) {
Text("Increment")
}
}
}- Stores value in Bundle (for configuration changes)
- Uses SavedStateHandle internally (for process death)
- Survives rotation, locale changes, system UI mode changes
- Survives process death (with limitations)
Real-World Scenario: User List with Filters
Here's a realistic example showing when each is appropriate:
// ✅ GOOD: Search query should persist during rotation
@Composable
fun SearchScreen() {
// Remember search query across configuration changes
var searchQuery by rememberSaveable { mutableStateOf("") }
var selectedFilter by rememberSaveable { mutableStateOf(FilterType.ALL) }
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("Search users") },
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
// Filter chips - these should also persist
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterType.entries.forEach { filter ->
FilterChip(
selected = selectedFilter == filter,
onClick = { selectedFilter = filter },
label = { Text(filter.name) }
)
}
}
// Results list - this should NOT persist (re-fetch on config change)
val filteredUsers = remember(searchQuery, selectedFilter) {
// This CAN be regular remember - we want fresh data after config change
users.filter { user ->
matchesFilter(user, searchQuery, selectedFilter)
}
}
LazyColumn {
items(filteredUsers) { user ->
UserItem(user = user)
}
}
}
}When NOT to Use rememberSaveable
// ❌ BAD: Don't use rememberSaveable for everything!
@Composable
fun BadExample() {
// Heavy objects in rememberSaveable cause slow restores
var heavyObject by rememberSaveable { mutableStateOf(HeavyObject()) }
// ❌ CRITICAL: Never store ViewModels in rememberSaveable!
var viewModel by rememberSaveable { mutableStateOf(MyViewModel()) }
// ❌ DON'T: Store large lists that should be re-fetched
var allUsers by rememberSaveable { mutableStateOf(fetchAllUsers()) }
}- Heavy objects → Slow state restoration, serialized to Bundle
- ViewModels → Use
viewModel()composable instead - Large cached data → Should be re-fetched from repository
- Temporary UI state → Use regular
remember
Best Practice: ViewModel for Persistent State
// ✅ BEST: Use ViewModel for state that should survive config changes
class UserListViewModel : ViewModel() {
// ViewModel survives configuration changes automatically
// This is the recommended approach for most app state
private val _uiState = MutableStateFlow(UserListUiState())
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
fun search(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
}
@Composable
fun UserListScreen(viewModel: UserListViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
// Compose with ViewModel - automatic survival of config changes!
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = uiState.searchQuery,
onValueChange = { viewModel.search(it) },
label = { Text("Search") }
)
LazyColumn {
items(uiState.filteredUsers) { user ->
UserItem(user = user)
}
}
}
}Quick Decision Guide
🔴 Use rememberSaveable for:
- Simple counters, toggle states
- Search queries, filter selections
- Navigation state
- Small, serializable UI state
🟡 Use remember for:
- Heavy computed values (performance)
- Animation states
- Temporary local state
🟢 Use ViewModel + StateFlow for:
- App data, user data
- API responses, cached data
- Complex state logic
- State that should survive process death
The Key Takeaway Box
Key Takeaway
Use rememberSaveable for simple UI state (counters, toggles, search queries) that should survive rotation. For complex app state, user data, or anything that needs to survive process death → use ViewModel + StateFlow. Remember that remember alone only survives recomposition, not configuration changes!