Coroutines + Flow vs LiveData — when do you actually reach for each in 2025?
LiveData isn't dead, but it's no longer where new Android state management starts — here's the actual decision rule for greenfield Compose work versus legacy View-based screens.
LEARN BY DOINGPut your intuition to work3 steps
Coroutines + Flow vs LiveData — when do you actually reach for each in 2025?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Where LiveData still wins on paper
LiveData was designed for exactly one job: hold a value, notify observers, and automatically respect Lifecycle so a destroyed Activity/Fragment never gets called back and never leaks. observe(viewLifecycleOwner) { ... } stops delivering the moment the lifecycle drops below STARTED, and it resumes with the latest value on the way back up. For years that made it the default choice for exposing ViewModel state to a View-based UI, and it's genuinely simple: no coroutine scope to manage, no cold-vs-hot distinction to explain, trivial Java interop.
The catch is that LiveData is intentionally thin. It has map and switchMap and basically nothing else — no debounce, combine, distinctUntilChanged (well, that one exists, added later), flatMapLatest, or backpressure handling — and everything runs on the main thread by contract, so any transformation involving suspend functions or background work needs an escape hatch into coroutines anyway.
#Where Flow (and StateFlow/SharedFlow) took over
Flow is a first-class part of Kotlin coroutines, so anything already written as a suspend function or a coroutine composes naturally with it — no separate reactive framework to bolt on, no thread-confinement rule to work around. StateFlow gives you the "hold a current value, notify observers" behavior LiveData provided, but with a real operator chain, testability via libraries like Turbine, and a natural fit in Compose via collectAsStateWithLifecycle() — which, importantly, replicates the exact "stop collecting when not visible" behavior LiveData had, closing the gap that made collectAsState() (its lifecycle-unaware predecessor) risky in production for a while.
class SearchViewModel @Inject constructor(private val repository: SearchRepository,) : ViewModel() {private val query = MutableStateFlow("")val results: StateFlow<List<SearchResult>> = query.debounce(300).filter { it.length >= 2 }.distinctUntilChanged().flatMapLatest { repository.search(it) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())private val _events = MutableSharedFlow<SearchEvent>(extraBufferCapacity = 1)val events: SharedFlow<SearchEvent> = _events.asSharedFlow()fun onQueryChanged(text: String) {query.value = text}fun onResultClicked(result: SearchResult) {viewModelScope.launch { _events.emit(SearchEvent.OpenDetail(result.id)) }}}
That search-as-you-type pipeline — debounce then filter then distinctUntilChanged then flatMapLatest — is the single most common Flow interview example, precisely because LiveData has no equivalent way to express "cancel the in-flight search when a new keystroke arrives" without manually tracking and cancelling a Job.
@Composablefun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {val results by viewModel.results.collectAsStateWithLifecycle()LaunchedEffect(Unit) {viewModel.events.collect { event ->when (event) {is SearchEvent.OpenDetail -> { /* navigate to detail */ }}}}SearchResultsList(results = results)}
SharedFlow here (with extraBufferCapacity = 1, no replay) is doing the job that used to require a SingleLiveEvent hack on top of LiveData — a one-shot navigation/snackbar event that shouldn't re-fire just because the screen recreated and re-subscribed. StateFlow always replays its current value (that's the point, for state); SharedFlow with replay = 0 deliberately doesn't (that's the point, for events).
#The actual 2025 decision rule
- New Compose screens, or any business logic that's Flow-native already (Room, DataStore, most modern libraries expose Flow) —
StateFlowfor state,SharedFlowfor one-shot events, collected withcollectAsStateWithLifecycle(). This is the default; Google's own guidance and samples (Now in Android, the architecture guide) lead with Flow, andLiveData's own docs describe it as suited to simpler cases and Java interop rather than as the forward-looking recommendation. - A screen still built on the View system / Fragments, especially one mid-migration —
LiveDataat that boundary is still perfectly reasonable, or bridge withflow.asLiveData()so the underlyingViewModellogic stays Flow-based while the legacy screen keeps observingLiveDatathe way it always did. - Interop with a library that only exposes
LiveData(a notable one:WorkManager'sWorkInfoobservers) — convert at the boundary with.asFlow()rather than lettingLiveDataspread back into otherwise-Flow-based code. - Anything needing
combine,debounce,flatMapLatest, or work off the main thread — Flow, without much debate;LiveDatasimply doesn't have the vocabulary for it.
🧠 Check your understanding
0/2 · 0/2 answered1. Why does collectAsStateWithLifecycle() matter for Flow in Compose, compared to plain collectAsState()?
2. What's the risk of using a SharedFlow with the default replay = 0 and no extraBufferCapacity for a one-shot UI event emitted from init { }?