Kotlin Coroutines and Flow have fundamentally reshaped how modern Android applications are designed. They did not simply replace callbacks or RxJava operators — they introduced a different way of thinking about time, state, and events.
Among coroutine-based primitives, StateFlow and SharedFlow are often introduced as “hot flows” or “better LiveData.” While this comparison helps onboarding, it also hides their true purpose. These APIs are not conveniences — they encode architectural constraints.
This article explains why those constraints exist, what problems they solve, and how to use them correctly in real UI systems. The goal is not to memorize APIs, but to understand the underlying model so you can reason about correctness, lifecycle, and scalability.
Why LiveData Is Not Enough
LiveData was designed to solve a specific Android-era problem: safely updating UI components in a lifecycle-aware way. For many years, it worked reasonably well. However, modern Android architectures demand guarantees that LiveData simply does not provide.
LiveData Has No Clear Semantic Boundary Between State and Events
LiveData treats all emissions equally. Whether you emit:
- a screen state,
- a navigation command,
- or a toast message,
they are all just “values.”
This is problematic because state and events have fundamentally different lifecycles.
- State is something that exists at any moment in time.
- Events are something that happen at a moment in time.
LiveData has no way to encode this difference.
As a result, developers invented patterns like:
SingleLiveEventEvent<T>wrappers- manual consumption flags
All of these are attempts to patch a missing concept.
LiveData Always Replays the Last Value
LiveData’s replay behavior is implicit and uncontrollable:
- Any new observer immediately receives the last emitted value
This is correct for state, but dangerous for events.
A navigation command or snackbar message:
- may re-trigger after rotation
- may trigger again when returning from background
- may fire in unexpected lifecycle transitions
The core issue is not misuse — it is a mismatch between semantics and abstraction.
LiveData Is UI-Bound by Design
LiveData depends on LifecycleOwner, which makes it:
- impossible to use in pure domain logic
- awkward in shared or multiplatform modules
- unsuitable for non-UI consumers
This creates artificial boundaries in architecture and encourages logic leakage into ViewModels.
LiveData Is Not a Stream Model
LiveData:
- has no backpressure
- has no buffering
- has no composition model
- is push-only
Flow-based APIs, in contrast, model streams of values over time, which makes them suitable for both UI and non-UI layers.
The Real Limitation
LiveData answers:
“How do I notify the UI safely?”
Modern architectures ask:
- How do I represent state vs events explicitly?
- How do I avoid accidental replays?
- How do I unify lifecycle, concurrency, and data flow?
StateFlow and SharedFlow exist to answer those questions.
StateFlow vs SharedFlow — Core Differences
Both StateFlow and SharedFlow are hot flows, meaning they exist independently of collectors. However, their contracts are intentionally different.
Understanding these contracts is more important than memorizing API differences.
StateFlow: A State Container with Stream Semantics
StateFlow is best understood as:
An always-available, observable snapshot of state
Key properties:
- It always has a value
- It replays exactly one value (the current state)
- It never completes or fails
- It emits only when the value changes (
equalscheck)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state
This design enforces a crucial invariant:
At any time, there is exactly one authoritative state.
SharedFlow: A Configurable Broadcast Stream
SharedFlow is intentionally more flexible:
A hot stream that broadcasts values to multiple collectors
Key properties:
- It may start empty
- Replay is configurable
- Buffering is configurable
- No built-in notion of “state”
private val _events = MutableSharedFlow<UiEvent>()
val events = _events.asSharedFlow()
SharedFlow does not tell you what it represents — it lets you define the semantics.
The Mental Model
- StateFlow answers: “What is the current truth?”
- SharedFlow answers: “What just happened?”
Confusing these two responsibilities leads to fragile architectures.
Replay, Buffering, and State Semantics
This section explains why Flow configuration matters — and why defaults are rarely accidental.
Replay Is About Late Subscribers
Replay defines:
How many past emissions are delivered to a new collector
- StateFlow: replay is always
1 - SharedFlow: replay is explicit and configurable
Replay is not a performance feature — it is a semantic decision.
Buffering Is About Producer–Consumer Mismatch
SharedFlow allows you to define:
- how much data can be buffered
- what happens when the buffer overflows
This matters for UI systems because:
- producers (ViewModels) should not block
- consumers (UI) may be paused or slow
Intentional data loss is sometimes the correct behavior for events.
Why State Should Not Be Modeled with SharedFlow
Using SharedFlow to model state (e.g. replay = 1) removes:
- equality checks
- direct state access
- guarantees of single source of truth
StateFlow encodes state invariants. SharedFlow does not.
Using StateFlow in ViewModels
StateFlow aligns naturally with unidirectional data flow (UDF) and immutable state modeling.
How StateFlow Is Created
In practice, StateFlow is created via MutableStateFlow:
val state = MutableStateFlow(initialValue)
This simplicity is intentional.
There is only one way to create a StateFlow because:
- StateFlow is not a general-purpose stream
- It is a state container with strict invariants
The initialValue Parameter
MutableStateFlow(initialValue: T)
The initialValue is not a convenience — it is a contract.
Why is an initial value required?
Because state must always be defined.
If a screen can exist:
- before data loads
- after rotation
- after process recreation
then its state must be meaningful in all those moments.
This forces you to answer:
- What does the UI look like before data arrives?
- What is the empty or loading state?
This eliminates:
- null-based ambiguity
- “no data yet” hacks
- temporal coupling between producer and consumer
Equality-Based Emission
StateFlow emits only when equals() returns false.
_state.value = sameState // no emission
This has deep implications:
- Prevents redundant recompositions
- Encourages immutable data classes
- Makes UI rendering predictable
StateFlow is optimized for state transitions, not event streams.
Modeling UI State Explicitly
data class UserUiState(
val isLoading: Boolean = false,
val user: User? = null,
val error: String? = null
)
class UserViewModel : ViewModel() {
private val _state = MutableStateFlow(UserUiState())
val state: StateFlow<UserUiState> = _state
fun loadUser() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
runCatching { repository.fetchUser() }
.onSuccess { user ->
_state.value = UserUiState(user = user)
}
.onFailure { error ->
_state.value = UserUiState(error = error.message)
}
}
}
}
Why This Works Well
This approach works not because of syntax, but because it aligns with how UI systems fundamentally behave.
UI Is a Pure Function of State
When using StateFlow:
- the UI does not react to commands
- it reacts to state snapshots
This means:
- rendering is deterministic
- recomposition is safe
- UI bugs become reproducible
If a UI misbehaves, you inspect the state — not event history.
State Is Time-Independent
A critical property of state:
It must make sense regardless of when it is observed.
StateFlow enforces this by:
- always having a value
- replaying the latest value to new collectors
This eliminates entire classes of bugs related to:
- rotation
- process recreation
- delayed subscriptions
No Temporal Coupling Between Producer and Consumer
With callbacks or events:
- timing matters
- missing a signal causes undefined behavior
With StateFlow:
- collectors can start at any time
- the current truth is always available
This removes race conditions between:
- ViewModel initialization
- UI lifecycle transitions
Testability Improves Naturally
Testing becomes trivial:
- set a state
- assert UI output
No mocking lifecycle. No waiting for events. No manual synchronization.
Collecting State in UI
Compose:
val state by viewModel.state.collectAsState()
Fragments:
lifecycleScope.launchWhenStarted {
viewModel.state.collect { render(it) }
}
StateFlow Is Not for One-Off Actions
StateFlow should never represent one-time actions. Let’s examine why — case by case.
Navigation
Navigation is not state because:
- it has no meaning when re-observed
- re-triggering it causes incorrect behavior
If navigation lives in state:
- rotation may navigate again
- restoring process state may repeat actions
Navigation is a command, not a snapshot.
Snackbar / Toast
A snackbar:
- does not describe the screen
- does not persist meaningfully over time
If stored in state:
- it may reappear unexpectedly
- it pollutes state with transient concerns
Dialog Triggers
Dialogs are ephemeral UI reactions.
Persisting them as state introduces:
- stale UI
- awkward reset logic
- unclear ownership
The Core Rule
If something:
- should not survive rotation
- should not reappear after re-collection
- does not describe “what the screen looks like”
→ it is not state.
Using SharedFlow for Event Handling
SharedFlow is the correct abstraction for one-time or transient signals.
How SharedFlow Is Created
MutableSharedFlow<T>(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = SUSPEND
)
Each parameter directly affects temporal behavior.
Let’s analyze them one by one.
replay: Controlling What Late Collectors See
replay: Int
Defines:
How many past emissions are replayed to new collectors
Mental model:
replay = 0→ “only future events matter”replay = 1→ “the latest event still matters”replay > 1→ “event history matters”
Correct use cases:
replay = 0- Navigation
- Snackbar / Toast
- One-time UI commands
replay = 1- Login result
- Permission result
- Authentication required
Architectural warning:
If replay > 0, ask yourself:
“Am I accidentally modeling state?”
If yes — StateFlow is probably the correct abstraction.
extraBufferCapacity: Handling Slow Collectors
extraBufferCapacity: Int
Defines:
How many values can be emitted without suspending the producer
Why this matters:
- UI events should not block ViewModels
- Producers often outlive collectors
- Lifecycle pauses collection
Example:
MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 1
)
This allows:
- one event to be queued
- without blocking
emit()
onBufferOverflow: What Happens When the Buffer Is Full
onBufferOverflow: BufferOverflow
Options:
SUSPEND(default)DROP_OLDESTDROP_LATEST
Choosing the right strategy
- SUSPEND
- Strong delivery guarantee
- Dangerous for UI (may freeze producers)
- DROP_OLDEST
- Keeps the most recent signal
- Ideal for UI events
- DROP_LATEST
- Preserves earlier events
- Rarely useful for UI
For UI systems:
onBufferOverflow = DROP_OLDEST
is usually the safest choice.
Common SharedFlow Configurations
UI Event Flow
MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = DROP_OLDEST
)
Semantics:
- Fire-and-forget
- No replay
- Safe for lifecycle changes
Result Event Flow
MutableSharedFlow<AuthResult>(
replay = 1
)
Semantics:
- Late collectors still receive the result
- Behaves almost like state
- Must be used carefully
Why SharedFlow Does Not Enforce Semantics
SharedFlow is intentionally neutral.
It does not decide:
- whether values are state or events
- whether replay is correct
- whether loss is acceptable
This flexibility is power — and responsibility.
Defining UI Events
sealed interface UiEvent {
data class Navigate(val route: String) : UiEvent
data class ShowError(val message: String) : UiEvent
}
private val _events = MutableSharedFlow<UiEvent>()
val events = _events.asSharedFlow()
Collecting Events Safely
This pattern works because it respects the temporal nature of events.
lifecycleScope.launchWhenStarted {
viewModel.events.collect { event ->
when (event) {
is UiEvent.Navigate -> navigate(event.route)
is UiEvent.ShowError -> showToast(event.message)
}
}
}
Events Exist Only in Time, Not in State
An event:
- is meaningful only when it happens
- should not be replayed by default
replay = 0 ensures:
- no historical leakage
- no accidental re-consumption
Lifecycle Awareness Without Lifecycle Binding
SharedFlow:
- does not depend on lifecycle
- simply emits values
Lifecycle is handled by:
- starting and stopping collection
If the UI is not collecting:
- the event is dropped
- which is often the correct behavior
No Manual Consumption Logic
Unlike LiveData:
- no wrappers
- no flags
- no “handled” markers
Consumption is implicit in collection.
Clear Producer–Consumer Contract
The ViewModel says:
“I emit events. If someone is listening, they will receive them.”
The UI says:
“I listen while I am active.”
No hidden coupling. No assumptions.
When to Use Replay for Events
Replay for events is not wrong, but it must be intentional.
Use Replay When:
- The event represents a result, not an action
- Missing it would leave UI in an invalid state
Examples:
- Login success
- Permission result
- Authentication required
MutableSharedFlow<AuthEvent>(replay = 1)
Here, the event behaves almost like state — and that is acceptable.
Do NOT Use Replay When:
- The event triggers UI actions
- Re-execution would be harmful or confusing
Examples:
- Navigation
- Toasts
- Analytics logging
Replay here causes:
- duplicated behavior
- hidden bugs
- state–event confusion
A Simple Decision Rule
Ask:
“If this event is delivered twice, is the system still correct?”
- If yes, replay may be acceptable
- If no, replay must be
0
shareIn and stateIn — Turning Cold Flows into Hot, Shared Streams
So far, we have discussed StateFlow and SharedFlow as explicit hot streams that you usually create manually using MutableStateFlow or MutableSharedFlow.
However, in real-world applications, most data sources are cold flows:
- Repository flows
- Network request flows
- Database query flows
- Combination flows (
combine,flatMapLatest, etc.)
This raises an important architectural question:
How do we safely share a cold flow across multiple collectors without re-triggering its upstream work?
This is exactly what shareIn and stateIn are designed to solve.
Cold Flow vs Hot Flow — Why shareIn and stateIn Exist
A quick reminder:
- Cold flow
- Starts from scratch for each collector
- Executes upstream logic per collector
- Has no memory
val userFlow = flow {
emit(api.fetchUser())
}
Every collect:
- calls
fetchUser()again - repeats work
- may cause duplicated side effects
In UI systems, this is often incorrect or inefficient.
The Core Problem
Consider this scenario:
- One flow is collected by:
- the UI
- a logger
- a cache warmer
If the flow is cold:
- the API call runs multiple times
- state diverges
- performance degrades
We need:
- a single upstream execution
- multiple downstream collectors
That requires turning a cold flow into a hot, shared flow.
shareIn — Sharing a Flow Without State Semantics
What shareIn Does
fun <T> Flow<T>.shareIn(
scope: CoroutineScope,
started: SharingStarted,
replay: Int = 0
): SharedFlow<T>
shareIn:
- collects the upstream flow once
- shares emissions with multiple collectors
- returns a
SharedFlow
It does not impose state semantics.
When shareIn Is the Right Tool
Use shareIn when:
- The flow represents events or updates
- You want to avoid re-running upstream logic
- Late collectors may or may not need past emissions
Example:
val events = repository.eventsFlow
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
replay = 0
)
Semantics:
- One upstream subscription
- Multiple UI collectors
- No replay by default
Understanding the Parameters
scope
Defines:
The lifetime of the shared upstream collection
Important implications:
- When the scope is cancelled → upstream stops
- The shared flow dies with the scope
In UI:
viewModelScopeis almost always correct
started: SharingStarted
Controls:
When the upstream flow starts and stops collecting
This parameter is critical and often misunderstood.
Common options:
EagerlyLazilyWhileSubscribed(...)
We will deep dive into this shortly.
replay
Same semantics as SharedFlow.replay:
- Controls how many past values new collectors receive
Reminder:
replay > 0implies historical relevance- Choose intentionally
stateIn — Sharing a Flow as State
stateIn is conceptually similar to shareIn, but with stronger guarantees.
fun <T> Flow<T>.stateIn(
scope: CoroutineScope,
started: SharingStarted,
initialValue: T
): StateFlow<T>
What stateIn Does
stateIn:
- collects the upstream flow once
- converts it into a
StateFlow - always exposes a current value
This is not just sharing — it is state promotion.
When stateIn Is the Right Tool
Use stateIn when:
- The flow represents UI state
- The latest value must always be available
- Late collectors must see a valid snapshot
Example:
val uiState: StateFlow<UserUiState> =
repository.userFlow
.map { user -> UserUiState(user = user) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserUiState(isLoading = true)
)
This is extremely common in Compose-based architectures.
stateIn vs MutableStateFlow
This is a crucial distinction.
Use MutableStateFlow when:
- The ViewModel owns the state
- State is updated imperatively
- You explicitly control all transitions
private val _state = MutableStateFlow(UiState())
Use stateIn when:
- State is derived from another flow
- You want to expose computed state
- You want to avoid manual synchronization
val state = sourceFlow
.map { ... }
.stateIn(...)
Think of stateIn as:
“Make this flow behave like state.”
SharingStarted — The Most Subtle Parameter
SharingStarted defines when upstream collection begins and ends.
This is often where architectural bugs hide.
SharingStarted.Eagerly
started = SharingStarted.Eagerly
Behavior:
- Starts collecting immediately
- Never stops until scope is cancelled
Use when:
- Data must always stay fresh
- Upstream is cheap
- State must be ready before UI collects
Risk:
- Unnecessary work
- Background consumption
SharingStarted.Lazily
started = SharingStarted.Lazily
Behavior:
- Starts on first collector
- Never stops afterward
Use when:
- You want lazy initialization
- But want to keep data alive afterward
Risk:
- Long-lived resources
- Hidden background work
SharingStarted.WhileSubscribed(...)
SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000
)
Behavior:
- Starts when the first collector appears
- Stops after the last collector disappears (with delay)
Why this is ideal for UI:
- Respects lifecycle
- Avoids unnecessary background work
- Smoothly handles configuration changes
The timeout:
- prevents stop/start thrashing
- allows short UI pauses
Common shareIn / stateIn Patterns in ViewModels
Derived UI State (Recommended)
val uiState = repository.dataFlow
.map { UiState(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState()
)
Clear semantics:
- Single source
- State-driven UI
- Lifecycle-safe
Shared Event Stream
val events = repository.eventFlow
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
replay = 0
)
Clear semantics:
- No replay
- Broadcast-only
- No state leakage
Common Mistakes and Anti-Patterns
❌ Using shareIn When You Actually Need State
If you find yourself doing:
shareIn(..., replay = 1)
Ask:
“Am I modeling state?”
If yes — use stateIn.
❌ Using stateIn for Events
If the flow:
- triggers navigation
- shows a snackbar
- performs side effects
It is not state, no matter how convenient stateIn looks.
Final Mental Model
MutableStateFlow→ owned statestateIn→ derived stateMutableSharedFlow→ manual event streamshareIn→ shared cold flow
If you choose the right transformation, your architecture becomes:
- predictable
- lifecycle-safe
- easier to reason about
Final Thoughts
StateFlow and SharedFlow are not merely replacements for LiveData. They encode architectural intent.
- StateFlow enforces correctness through state modeling
- SharedFlow enables explicit, safe event handling
When used correctly, they eliminate:
- lifecycle hacks
- race conditions
- accidental replays
- ambiguous UI behavior
Most importantly, they force you to think clearly about time — which is the hardest part of UI architecture.
If you design with that in mind, the APIs will feel natural instead of magical.
