Fragment Lifecycle Explained
ANDROID › Lifecycle
Fragment lifecycle is a favorite interview filter, and not because the callback names are exotic. It's because a fragment secretly runs two lifecycles at once: its own, and its view's. Conflate them and you get real production bugs, an observer that keeps firing into a view that's already gone, a binding that leaks an entire destroyed view tree, a fragment that reruns setup it already did.
Interviewers use this topic to check whether you actually understand lifecycle ownership, not just whether you've memorized a list of callback names in order. Almost everything worth knowing here traces back to one fact: the fragment instance is the durable thing, its view is disposable, and the two don't always die together. Keep that distinction in mind and the rest of this lesson is just filling in the specifics.
Here's the split in concrete terms. The **fragment lifecycle** spans onAttach through onDetach. It can outlive its view: a fragment sitting on the back stack keeps its instance alive at the CREATED state even while it has no view on screen at all.
The **view lifecycle** is narrower. It starts once onCreateView returns a non-null View, and ends at onDestroyView. It's exposed through a separate LifecycleOwner called viewLifecycleOwner, and because a fragment can be pushed onto and popped off the back stack repeatedly, this view lifecycle can be created and destroyed multiple times within a single fragment lifecycle, once per trip through the back stack.
class DetailFragment : Fragment(R.layout.fragment_detail) {
// fragment lifecycle: created once per instance, onAttach..onDetach
// view lifecycle (viewLifecycleOwner): can restart repeatedly, onCreateView..onDestroyView
}
A fragment's position at any moment is described by one of five Lifecycle.State values: INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED. It climbs up to RESUMED and back down to DESTROYED, and once it leaves INITIALIZED it never re-enters it.
The first time a fragment is added and shown, the upward order is:
// onAttach -> onCreate -> onCreateView -> onViewCreated -> onViewStateRestored -> onStart -> onResume
onAttach hands you the hosting context. onCreate is one-time setup with no view yet. onCreateView inflates the layout. onViewCreated is where you wire up the now-existing view. Right after that comes onViewStateRestored, where any previously saved view hierarchy state, an EditText's typed text, a RecyclerView's scroll position, checked states, gets reapplied onto the freshly inflated view; that state simply isn't there yet during onViewCreated. Only then does the fragment climb to STARTED and RESUMED, the same two states an activity climbs through.
viewLifecycleOwner, and requireView(), are only valid between onCreateView returning non-null and onDestroyView. Call either outside that window and you get an IllegalStateException; there simply is no view lifecycle yet, or anymore.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requireView() // throws: no view exists at this point
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireView() // safe, the view now exists
viewLifecycleOwner // safe here too
}
The compiler can't stop you from calling these too early, so this exception is the runtime's way of catching a lifecycle-ordering mistake before it turns into a subtler bug.
Now the payoff of the two-lifecycle split: why observe LiveData with viewLifecycleOwner instead of the fragment itself as the LifecycleOwner? Because the fragment instance can outlive its view on the back stack. Observe with this and the observer keeps firing into a view that no longer exists after onDestroyView, a leak and a crash waiting to happen. viewLifecycleOwner is scoped to the view itself, so the observer is automatically removed the moment the view is destroyed, and a fresh viewLifecycleOwner is handed out the next time onCreateView runs.
// BAD: observer survives onDestroyView, holds a stale view
viewModel.data.observe(this) { render(it) }
// GOOD: tied to the view's own lifecycle
viewModel.data.observe(viewLifecycleOwner) { render(it) }
Teardown mirrors setup, with one twist: the view goes before the fragment instance does.
// onPause -> onStop -> onDestroyView -> onDestroy -> onDetach
onDestroyView runs before onDestroy because the view tears down first while the instance may still be needed, for example if it's about to be retained on the back stack. onDetach is always last, severing the connection to the host.
onDestroyView is also exactly where you must null out any view binding. The view is gone, but the fragment instance can live on, so holding onto the binding leaks the entire destroyed view tree.
override fun onDestroyView() {
super.onDestroyView()
_binding = null // view tree would leak otherwise
}
The same fix applies to Flow. Collecting a Flow with the fragment's own lifecycleScope has the identical problem as observing LiveData with this: it keeps updating a view that no longer exists once onDestroyView runs. Instead, tie collection to the view's lifespan with viewLifecycleOwner.lifecycleScope and repeatOnLifecycle:
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) } // cancels on onDestroyView, restarts on rebuild
}
}
repeatOnLifecycle(STARTED) cancels the collection when the view drops below STARTED, for instance when it's destroyed, and restarts it cleanly the next time the view is rebuilt and reaches STARTED again. One mental model, viewLifecycleOwner, scopes anything view-related, applies to both APIs.
A fragment's maximum reachable state is capped by its host, in three ways: by its host FragmentManager's own state, by an explicit setMaxLifecycle() call on a transaction, and by its parent in the fragment hierarchy, since a child can never exceed its parent fragment's or activity's state. A fragment can never be more resumed than whatever hosts it.
ViewPager2 uses the setMaxLifecycle mechanism internally: it caps offscreen pages so only the currently visible page reaches RESUMED, keeping adjacent pages' views ready to swipe to without letting them run as if fully active.
childFragmentManager.beginTransaction()
.setMaxLifecycle(someFragment, Lifecycle.State.___)
.commit()
The same cap applies top-down: if the hosting activity itself is only at STARTED, say it's partially obscured by a dialog, every fragment it hosts is bounded at STARTED too, no fragment can outrank its host.
addToBackStack changes what survives a replace(). With it, the outgoing fragment's view is destroyed via onDestroyView, but the instance is retained at CREATED. Press Back and the view rebuilds via onCreateView again, skipping onAttach and onCreate entirely since the instance never went away. Without addToBackStack, the outgoing fragment is fully destroyed, both onDestroy and onDetach run, and it's gone for good; showing it again means creating a brand new instance, never reuse a destroyed fragment instance.
supportFragmentManager.beginTransaction()
.replace(R.id.container, DetailFragment())
.addToBackStack(null)
.commit()
add() instead of replace() layers a new fragment on top without removing the old one. The old fragment's view stays attached in the container, and its onDestroyView never runs at all.
A fragment can host its own nested fragments, and it needs its own FragmentManager to do it: childFragmentManager. Transactions run through it are scoped to the hosting fragment's own view lifecycle and get their own independent back stack, separate from the activity's.
class ParentFragment : Fragment(R.layout.fragment_parent) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
childFragmentManager.beginTransaction()
.replace(R.id.child_container, ChildFragment())
.commit()
}
}
parentFragmentManager, despite the similar name, means something different: it's the FragmentManager that hosts the current fragment itself, whichever manager called add() or replace() to put this fragment on screen in the first place. Reach for childFragmentManager when nesting fragments inside a fragment, tabs within a fragment, a ViewPager2 living inside a fragment. Reach for parentFragmentManager when you need to manipulate the fragment that hosts you.
Fragments talking to each other used to mean reaching into a target fragment directly, or routing everything through a shared ViewModel. The Fragment Result API gives a simpler, decoupled option: setFragmentResult() stores a keyed Bundle on the FragmentManager, and setFragmentResultListener() on the receiving side picks it up by that same key, no direct reference between the two fragments needed.
// Sender
setFragmentResult("requestKey", bundleOf("data" to "hello"))
// Receiver
setFragmentResultListener("requestKey") { _, bundle ->
updateUi(bundle.getString("data"))
}
Delivery isn't instant regardless of state. The FragmentManager holds onto the result and only hands it to the listener once that listener's own lifecycle reaches at least STARTED. If the receiving fragment is currently stopped, the result just waits, queued, until it comes back into view.
FragmentTransaction.commit() doesn't run the transaction immediately. It posts it onto the main looper's message queue, so the change actually lands on a later iteration of the main loop, not synchronously at the commit() call itself.
supportFragmentManager.beginTransaction()
.replace(R.id.container, DetailFragment())
.addToBackStack(null)
.commit() // posted, runs a moment later
val immediate = supportFragmentManager.findFragmentById(R.id.container)
commitNow() skips the queue and runs the transaction synchronously on the calling thread, useful when the very next line of code needs the new fragment to already exist. The catch: commitNow() is incompatible with addToBackStack; a transaction meant to be reversible by the back button has to go through the normal, posted commit() path instead. To force a posted commit() to run early, call executePendingTransactions().
Zoom out and everything in this lesson is one idea applied repeatedly: the fragment instance is durable, its view is disposable, and FragmentManager, the back stack, and the host all exist to manage that gap. Observe state with viewLifecycleOwner, not the fragment itself. Clear view and binding references in onDestroyView, not onDestroy. Respect the host's cap on how resumed a fragment is allowed to be.
That same durability question used to have a different answer for surviving configuration changes. setRetainInstance(true) told the FragmentManager to keep the fragment instance alive across the activity recreation a rotation triggers. It's deprecated now, because a retained instance still juggled onCreate and onDestroy calls around the surrounding activity's recreation, and view references could dangle since the fragment itself never went away. A ViewModel replaces it cleanly: it survives the same configuration changes, scoped to the fragment, with none of that lifecycle entanglement.
// Deprecated, don't reach for this
// setRetainInstance(true)
class MyFragment : Fragment(R.layout.fragment_my) {
private val viewModel: MyViewModel by viewModels() // survives rotation cleanly
}
If an interviewer asks you to explain fragment lifecycle in one breath, this is it: a fragment has two lifecycles because its view is disposable and its instance isn't, viewLifecycleOwner exists to scope observers and coroutines to the disposable half, and everything else, the back stack, setMaxLifecycle, ViewModel over retained instances, is machinery built around keeping that distinction safe.