SharedFlow & StateFlow — Hot Streams for UI and API Systems

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.


Table of Contents

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:

  • SingleLiveEvent
  • Event<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 (equals check)
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_OLDEST
  • DROP_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:

  • viewModelScope is almost always correct

started: SharingStarted

Controls:

When the upstream flow starts and stops collecting

This parameter is critical and often misunderstood.

Common options:

  • Eagerly
  • Lazily
  • WhileSubscribed(...)

We will deep dive into this shortly.


replay

Same semantics as SharedFlow.replay:

  • Controls how many past values new collectors receive

Reminder:

  • replay > 0 implies 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

  • MutableStateFlowowned state
  • stateInderived state
  • MutableSharedFlowmanual event stream
  • shareInshared 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.

Leave a Reply

Your email address will not be published. Required fields are marked *