Stop Losing UI State on Rotation: ViewModel and LiveData Done Right
Activities get destroyed on rotation and take your UI state with them. Here's how ViewModel + LiveData fixes that, with a working counter example and three ways to verify the lifecycle behavior yourself.
17 Oct 2025, 17:15 UTC

Rotate a phone mid-form and watch the screen rebuild itself from scratch — the counter resets, the list refetches, the network spinner comes back. That behavior isn't a bug in your code so much as a missing layer: the Activity is destroyed and recreated on configuration changes, and anything stored only inside it goes with it. The fix that has held up well across Android releases is a small, deliberate split: keep UI state in a ViewModel, expose it as LiveData, and let the Activity or Fragment do nothing but render.
Why the Activity is the wrong place for state
Configuration changes — rotation, dark mode toggle, locale switch, multi-window resize — destroy and recreate the Activity by design. If your data lives in Activity fields, you either lose it or hand-roll save/restore logic with onSaveInstanceState, which only handles small bundles of primitives anyway. A ViewModel survives these recreations because it's scoped to the LifecycleOwner through ViewModelProvider: the system keeps the instance alive as long as the Activity is being recreated, and clears it only when the Activity truly finishes. That gives you a stable place for state without any manual bookkeeping.
LiveData: observation that respects the lifecycle
LiveData is an observable data holder that knows about lifecycles. It only pushes updates to observers whose LifecycleOwner is in an active state (started or resumed), and it removes observers automatically when the owner is destroyed. Two practical consequences: you stop writing manual unsubscribe code in onDestroy, and you stop getting crashes from callbacks firing into a destroyed Fragment. When a Fragment returns from the background, LiveData delivers the latest value, so the UI catches up without extra logic.
A minimal worked example
A counter screen is the smallest case that demonstrates the whole pattern. The ViewModel:
class CounterViewModel : ViewModel() {
private val _count = MutableLiveData(0)
val count: LiveData<Int> = _count
fun increment() {
_count.value = (_count.value ?: 0) + 1
}
}And the Fragment observes it:
class CounterFragment : Fragment(R.layout.fragment_counter) {
private val viewModel: CounterViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val text = view.findViewById<TextView>(R.id.counter_text)
view.findViewById<Button>(R.id.increment_button)
.setOnClickListener { viewModel.increment() }
viewModel.count.observe(viewLifecycleOwner) { value ->
text.text = value.toString()
}
}
}Two details matter here. First, the Fragment exposes the LiveData as read-only (LiveData) and keeps the MutableLiveData private, so the UI can't write state directly — all mutations go through the ViewModel's methods. Second, observe uses viewLifecycleOwner, not the Fragment itself. Fragments outlive their views (the view is destroyed in onDestroyView while the Fragment instance may be retained), so observing with the wrong owner is a classic leak source.
How to verify it actually works
Don't take the lifecycle behavior on faith — it's cheap to check:
- Rotation test: increment the counter a few times, rotate the device, and confirm the value persists without a refetch or reset.
- Background test: add a timer in the ViewModel that updates the LiveData every second, press home, and confirm via a log statement in the observer that no updates are dispatched while the app is backgrounded — then that the latest value arrives on return.
- Cleanup test: navigate away so the Fragment is destroyed, and check in Logcat or with a debugger breakpoint that the observer no longer fires. LiveData should have removed it automatically.
Trade-offs and where the pattern breaks down
The pattern has sharp edges worth knowing up front. Never hold a Context, View, or Fragment reference inside a ViewModel — the ViewModel outlives the recreated Activity, so any such reference leaks the entire old instance. If you need a context, use AndroidViewModel's application context or better, inject what you need. Second, LiveData is a UI-layer tool; putting it in your repository or domain layer couples business logic to Android framework classes and makes plain-JVM unit tests harder. Map to LiveData (or StateFlow) at the boundary. Third, LiveData holds a single latest value — it's a poor fit for large lists. Use the Paging library, which streams data in chunks, rather than loading thousands of rows into one LiveData object. Finally, if you're starting a new Kotlin-first project, StateFlow is the more modern choice with better coroutine integration; LiveData remains fully supported and is still the pragmatic pick in Java codebases and older projects.
The takeaway
The engineering decision is really about drawing one clean line: the ViewModel owns state and logic, the UI owns rendering, and LiveData is the lifecycle-aware bridge between them. Start with the counter example above, run the three verification checks, and you'll have the pattern working end-to-end in an afternoon — along with a concrete way to prove it's doing what the documentation says it does.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.