Handling configuration changes: best practices explained
Configuration changes—screen rotations, locale switches, dark mode toggles—happen constantly in production apps, yet many teams still struggle to preserve application state smoothly across them. When users rotate their device, they expect their scroll position, form input, and async work to survive intact; losing that state creates friction and feels like a regression. This guide covers the modern Android patterns that keep your app responsive and stateful during these transitions.
Why configuration changes matter
Android recreates the entire Activity (and sometimes Fragments) when a configuration change occurs. The system destroys the old instance and creates a new one with fresh resources—a necessary evil to pick up new layouts, dimensions, or strings for the new configuration. Without deliberate state management, everything in memory vanishes: user input, network request results, scroll positions, and UI state all reset to their defaults.
In early Android development, teams would lock orientation with android:screenOrientation="portrait" to sidestep the problem. That's a band-aid. Modern apps support rotation and device folds; your state handling must be robust.
ViewModels survive configuration changes
The ViewModel is your first line of defense. Unlike Activity instances, ViewModels persist across configuration changes—the system keeps them alive and hands them back to the new Activity instance. This is the single most important pattern for state survival in modern Android.
Here's a concrete scenario: a user is browsing a list of items, scrolls down, and rotates their phone. Without a ViewModel, the list resets to the top and shows loading spinners again. With a ViewModel, the list data is already loaded and cached, and you can restore scroll position immediately.
class ItemListViewModel : ViewModel() {
private val _items = MutableLiveData<List<Item>>()
val items: LiveData<List<Item>> = _items
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> = _isLoading
private val repository = ItemRepository()
fun loadItems() {
if (_items.value != null) {
// Data already loaded; skip redundant fetch
return
}
_isLoading.value = true
viewModelScope.launch {
try {
_items.value = repository.fetchItems()
} finally {
_isLoading.value = false
}
}
}
override fun onCleared() {
super.onCleared()
// Cleanup: cancel coroutines, close connections
repository.close()
}
}
The key insight is the if (_items.value != null) guard. On rotation, the Activity is recreated, but the ViewModel's data survives. The new Activity observes the same LiveData, gets the cached data immediately, and never re-fetches. Coroutines in viewModelScope are automatically cancelled when the ViewModel clears, preventing leaks.
Pair this with a Fragment that observes the ViewModel:
class ItemListFragment : Fragment(R.layout.fragment_item_list) {
private val viewModel: ItemListViewModel by viewModels()
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: ItemAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView = view.findViewById(R.id.recycler_view)
adapter = ItemAdapter()
recyclerView.adapter = adapter
viewModel.items.observe(viewLifecycleOwner) { items ->
adapter.submitList(items)
}
viewModel.isLoading.observe(viewLifecycleOwner) { loading ->
// Update UI: show/hide spinner
}
viewModel.loadItems()
}
}
On rotation, onViewCreated runs again, but viewModel.loadItems() skips the fetch because data is already cached. The observer receives the cached list and updates the adapter instantly.
rememberSaveable for UI state in Compose
In Jetpack Compose, the equivalent pattern uses rememberSaveable for lightweight UI state that must survive configuration changes. Unlike remember, which lives only for the current composition, rememberSaveable persists across configuration changes by storing state in the Bundle.
Consider a search screen where users type a query, rotate the device, and expect their text to remain:
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
var query by rememberSaveable { mutableStateOf("") }
val results by viewModel.results.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
TextField(
value = query,
onValueChange = { query = it },
label = { Text("Search") },
modifier = Modifier.fillMaxWidth()
)
LazyColumn {
items(results) { result ->
ResultItem(result)
}
}
}
LaunchedEffect(query) {
viewModel.search(query)
}
}
When rotation occurs, the Compose runtime restores query from the saved state, the TextField shows the previous text, and LaunchedEffect re-triggers the search. No manual Bundle handling needed.
For complex state objects, provide a custom Saver:
data class FilterState(val category: String, val priceMax: Int)
val filterStateSaver = run {
val categoryKey = "category"
val priceMaxKey = "priceMax"
mapSaver(
save = { mapOf(categoryKey to it.category, priceMaxKey to it.priceMax) },
restore = { FilterState(it[categoryKey] as String, it[priceMaxKey] as Int) }
)
}
@Composable
fun FilterScreen() {
var filterState by rememberSaveable(saver = filterStateSaver) {
mutableStateOf(FilterState("all", 1000))
}
// Use filterState...
}
The mapSaver converts complex objects to and from Maps, which Android can serialize into Bundles. This keeps your UI state intact across configuration changes without coupling to the ViewModel.
Avoid locking orientation as a fix
It's tempting to add android:screenOrientation="portrait" in the manifest to "fix" configuration change bugs. Resist that temptation in production apps. Users with accessibility needs, foldable devices, or specific orientations for their use case will find your app broken.
Locking orientation also masks underlying state management problems that will resurface when you support rotation later or when users force orientation changes via system settings. Better to solve it properly now.
If you genuinely need to prevent rotation for a specific screen—say, a camera preview that only works in portrait—use conditional logic and document it:
class CameraActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Lock to portrait only for camera preview
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContent {
CameraScreen()
}
}
}
Even then, handle the rotation event gracefully—freeze the preview, save any captures, and notify the user. Your state should survive the transition.
Test with rotation and dark mode
Configuration changes aren't limited to orientation. Dark mode toggles, locale changes, and font size adjustments all trigger recreations. Write tests that verify state survival across these transitions.
In an instrumented test, use the Espresso framework or Compose testing API to rotate and assert state:
@HiltAndroidTest
class ItemListScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun testStatePreservedOnRotation() {
composeTestRule.setContent {
ItemListScreen()
}
// Verify initial state
composeTestRule.onNodeWithText("Item 1").assertIsDisplayed()
// Scroll to an item
composeTestRule.onNodeWithTag("list").performScrollToNode(
hasText("Item 50")
)
// Rotate device
val activity = composeTestRule.activity
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
// Wait for recomposition
composeTestRule.waitForIdle()
// Verify state survived
composeTestRule.onNodeWithText("Item 50").assertIsDisplayed()
}
}
For dark mode, toggle the theme and verify colors and state persist:
@Test
fun testStatePreservedOnDarkModeToggle() {
composeTestRule.setContent {
ItemListScreen()
}
composeTestRule.onNodeWithText("Item 1").assertIsDisplayed()
// Simulate dark mode toggle
composeTestRule.activity.delegate.applyDayNight()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Item 1").assertIsDisplayed()
}
Run these tests on physical devices and emulators with various screen sizes and Android versions. Configuration change bugs often surface in edge cases.
Common mistakes
- Storing UI state in the Activity. Any mutable property on the Activity is lost on rotation. Move it to a ViewModel or use
rememberSaveablein Compose. - Reloading data unconditionally in onViewCreated or onResume. Check if data already exists in the ViewModel before fetching. Redundant network calls drain battery and slow the app.
- Forgetting to cancel coroutines. If you launch coroutines outside
viewModelScope, they'll outlive the Activity and leak. Always use the appropriate scope. - Saving large objects in Bundle. The Bundle passed to
onSaveInstanceStateis serialized and has size limits. For large data, keep it in the ViewModel and save only a small key or ID to the Bundle. - Assuming dark mode won't trigger a recreation. On Android 10+, theme changes do recreate the Activity. Test both light and dark mode.
Key takeaways
- ViewModels are the primary mechanism for surviving configuration changes—they persist across Activity recreation while the Activity itself is destroyed and rebuilt.
- In Compose, use
rememberSaveablefor UI state that must survive rotation; provide custom Savers for complex objects. - Never lock orientation to sidestep configuration change handling. Modern apps must support rotation and other device configurations.
- Test rotation, dark mode toggles, locale changes, and font size adjustments. Configuration changes are frequent in real-world use.
- Guard against redundant data loads by checking if your ViewModel already has cached data before fetching from the network.
- Properly scope coroutines using
viewModelScopeto avoid leaks when Activities are recreated.






Comments
Loading comments…