Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 039 / 100

Handling configuration changes: best practices explained

Rotation without losing state.

Expona Admin7 min read

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.

kotlin
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:

kotlin
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:

kotlin
@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:

kotlin
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:

kotlin
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:

kotlin
@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:

kotlin
@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 rememberSaveable in 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 onSaveInstanceState is 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 rememberSaveable for 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 viewModelScope to avoid leaks when Activities are recreated.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles