StateFlow & SharedFlow Explained

KOTLIN › Flow

Interviewers reach for StateFlow and SharedFlow to check whether you can keep UI state and one-off events separate, and whether you can collect a flow without leaking background work or crashing the app. Get the choice wrong and you get concrete bugs: a navigation event that refires after a rotation, a snackbar that pops up twice, or a coroutine that keeps polling a server long after the screen left view.

Both types are **hot** flows, and hot is the direct opposite of the cold flow { } builder you've already met. A cold flow does nothing until collected, and reruns its producer block from scratch for every single collector. A hot flow is already running: it holds its data in memory, and every collector taps into the same live stream. No rerun, no restart, and every collector sees the same emissions as every other collector.

A Channel is different again. It's built for single delivery: each item goes to exactly one collector and is then removed, which is not what you want when several parts of the UI need to observe the same state.

StateFlow and SharedFlow both give you that shared, always-running stream. What separates them is what kind of thing each one is built to hold, continuous state, or discrete events, and that distinction runs through everything else in this lesson.

StateFlow is a **state holder**. It must always expose a current value through .value, which is why its constructor requires an initial value, there's no such thing as a StateFlow with nothing in it. Whatever you seed it with is what any collector sees the instant it starts collecting, and .value is always readable synchronously without collecting at all.

class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun load(data: Data) {
        _state.value = UiState.Success(data)
    }
}

That's exactly the shape screen state wants: one source of truth held in memory, and a value that's always there for every observer, whether it's the original Activity, a rotated Activity, or a preview pane collecting for the first time. SharedFlow, by contrast, models events rather than state, so it needs no seed at all. By default it starts with replay = 0 and no current value to speak of.

StateFlow also **conflates**. It only emits when the new value differs from the current one, compared with equals(), the same rule a built-in distinctUntilChanged() would apply. Two consequences follow from that.

First, there's no queue behind it. If a producer sets .value faster than a collector can keep up, the collector isn't guaranteed to see every intermediate value, only that it eventually converges on the latest one.

val state = MutableStateFlow(0)
launch { repeat(100) { state.value = it } }   // fast emitter
launch { state.collect { delay(50); println(it) } }   // slow collector, skips intermediates

Second, assigning a value that's equals() to the current one produces no emission at all, even if it's a structurally identical but freshly constructed object.

data class UiState(val count: Int)
val state = MutableStateFlow(UiState(count = 5))
state.value = UiState(count = 5)   // no emission: equal to current value
state.value = UiState(count = 6)   // emits: value actually changed

Notice the shape of that ViewModel snippet from before: a private MutableStateFlow backing field, and a public val exposed through .asStateFlow(). That's the idiomatic way to publish a StateFlow. .asStateFlow() returns a read-only view over the same underlying flow, so outside callers can read .value and collect updates, but they can't assign .value or otherwise push a new emission in. Only code inside the class, holding a reference to the private mutable backing field, can do that.

This matters for anything that owns state it wants to control: a counter that only its own increment function should bump, a login flow that only its own login function should update. Casting a StateFlow back to MutableStateFlow at a call site would defeat this, so the private-field-plus-asStateFlow() pattern is what actually enforces the read-only boundary rather than merely suggesting it.

A read-modify-write on .value is not atomic. state.value = state.value.copy(count = state.value.count + 1) is two separate steps, a read and then a write, and two coroutines racing that line can both read the same starting value, so one of their updates gets silently overwritten.

// BAD: races under concurrent access, can lose an update
state.value = state.value.copy(count = state.value.count + 1)

// GOOD: update() retries atomically
state.update { it.copy(count = it.count + 1) }

MutableStateFlow.update { } runs its lambda inside a compare-and-set retry loop. If another writer changed the value in between the read and the write, it recomputes the lambda against the fresh value and tries again, so no concurrent update is lost, and no lock is needed. Any time two callers might bump the same StateFlow at once, update is the version that's actually safe.

MutableSharedFlow exposes three knobs StateFlow doesn't have. replay is how many of the most recent values a brand-new collector receives immediately on subscribing. extraBufferCapacity is buffer room beyond replay to absorb a burst of emissions for a collector that's momentarily behind. onBufferOverflow decides what happens once that buffer is full: the default is SUSPEND, and the alternatives are DROP_OLDEST and DROP_LATEST.

Replay is worth seeing in action. With replay = 2, a collector that subscribes after five values have already gone out still gets the last two of them first, then continues with whatever's emitted after that.

val sharedFlow = MutableSharedFlow<Int>(replay = 2)
sharedFlow.emit(1); sharedFlow.emit(2); sharedFlow.emit(3)
sharedFlow.emit(4); sharedFlow.emit(5)
// late collector receives 4, 5 first, then any new emissions

There are also two ways to push a value in. emit() is a suspend function: under the default SUSPEND policy it waits for buffer space. tryEmit() never suspends, it attempts the push and returns a Boolean, true if there was room or a drop policy applied, false otherwise.

There's a sharp edge in the default MutableSharedFlow that trips people up: it does not buffer or wait for subscribers by default. With replay = 0 and no extra buffer capacity, a call to emit() only has to wait for subscribers that already exist at the moment of the call. If there are zero subscribers, emit() returns immediately, without suspending at all, and the value is simply gone. Nobody received it, and nothing replays it to a subscriber that arrives a moment later.

That's different from a subscriber being attached but not yet ready to accept the value, say it's still processing the previous emission. In that case the default SUSPEND overflow policy does exactly what its name says: emit() suspends the caller until that subscriber catches up and takes the new value.

val events = MutableSharedFlow<Int>() // replay = 0, no extra buffer

// No collector yet: emit() returns immediately, the value is lost
events.emit(1)

launch { events.collect { println(it) } }

// Now a collector exists: emit() suspends here until that collector
// is ready to take the value
events.emit(2)

If you need every emission to be seen even when a subscriber hasn't shown up yet, that's what replay is for, it isn't something the default configuration gives you for free.

Choosing between StateFlow and SharedFlow comes down to state versus events. Screen state, 'here's what's on screen right now', belongs in StateFlow: it always has a current value, and a late collector, say one that appears after a rotation recreates the Activity, correctly sees that current state.

That exact behavior is what makes StateFlow the wrong tool for a one-off event. Model a navigation command as StateFlow and a new collector immediately receives whatever was last set, refiring navigation it already handled.

// BAD: a late collector immediately re-triggers the 'event'
val navEvent = MutableStateFlow<Screen?>(null)

// GOOD: replay = 0 means a late collector misses an already-delivered event
val navEvent = MutableSharedFlow<Screen>(replay = 0)

SharedFlow with replay = 0 only delivers a value to whoever is actively collecting at the moment it's emitted, and nobody who subscribes afterward. That's fire-once semantics, which is exactly what a navigation command, a snackbar, or a one-shot dialog needs.

MutableSharedFlow exposes two members people never touch until they need them, and both come up when events go missing.

**subscriptionCount** is itself a StateFlow<Int>, so you can observe when anyone is listening:

private val _events = MutableSharedFlow<Event>()

init {
    _events.subscriptionCount
        .map { it > 0 }
        .distinctUntilChanged()
        .onEach { active -> if (active) startPolling() else stopPolling() }
        .launchIn(viewModelScope)
}

That is the mechanism SharingStarted.WhileSubscribed is built on, and knowing it is what turns that policy from magic into machinery.

**resetReplayCache()** clears the replayed values without emitting anything:

_events.resetReplayCache()   // new subscribers get nothing, not the old value

This is the honest fix for the replay-a-stale-event problem. If you use replay = 1 so an event survives a rotation, a subscriber arriving much later still receives it, and calling resetReplayCache() once handled is how you stop that.

It is worth being clear that this is a workaround rather than a design. Reaching for it usually means SharedFlow was the wrong tool and the events wanted a Channel, which is the subject of the next chunk. StateFlow has no equivalent at all, because it must always have a current value, so there is nothing to reset it to.

The hardest question in this topic is not StateFlow versus SharedFlow, it is whether **either** is right for one-off events. There is a genuine argument that the answer is a Channel.

The problem with SharedFlow for events is delivery. With replay = 0 and no subscriber, an emitted event is **dropped silently**:

private val _events = MutableSharedFlow<Event>()   // replay = 0

// If the screen is backgrounded and nothing is collecting,
// this event is gone forever.
_events.emit(Event.ShowError)

With replay = 1 you fix that and create the opposite bug: rotate the device and the new collector receives the old event again, so the snackbar shows twice or you navigate twice.

A Channel has neither problem, because it **buffers** and delivers each element **exactly once**:

private val _events = Channel<Event>(Channel.BUFFERED)
val events = _events.receiveAsFlow()     // each event to exactly one collector

// Emitted with no collector: buffered, delivered when one arrives.
// Delivered once: a rotation does not replay it.
_events.send(Event.ShowError)

The trade-off, and you should name it: a Channel supports exactly **one** collector. Two collectors split the events between them rather than both receiving each, which is a nasty bug if a second one appears. SharedFlow broadcasts to all.

So: StateFlow for state, SharedFlow for events genuinely broadcast to several listeners, Channel plus receiveAsFlow() for one-off events consumed by a single screen. That last one is what most Android event buses should be.

Interviewers migrating a codebase will often ask you to compare StateFlow with LiveData, and a good answer is specific rather than "coroutines are newer".

**What StateFlow does better:**

- It is a Flow, so the whole operator library applies: map, combine, debounce, flatMapLatest. LiveData has map and switchMap and little else. - It is pure Kotlin with no Android dependency, so a ViewModel using it is unit-testable on the JVM with no Robolectric and no InstantTaskExecutorRule, and shareable in multiplatform code. - Its null-safety is the language's. LiveData<T> is always effectively nullable because its value starts unset.

**What LiveData does for you that StateFlow does not:**

- Lifecycle awareness is **built in**. observe(viewLifecycleOwner) is automatically STARTED-aware. StateFlow needs repeatOnLifecycle or collectAsStateWithLifecycle and it is your job to remember.

That is the trade the API made: more power, less safety by default. Most Flow bugs on Android come from that one line.

**Where they behave the same:** both are hot, both conflate, and both hold a current value. So the same "why is my duplicate value not emitted" surprise exists in each.

// Interop while migrating, in both directions
val live: LiveData<UiState> = stateFlow.asLiveData()
val flow: Flow<UiState> = liveData.asFlow()

For a partial migration, asLiveData() on a repository flow lets new code use Flow while existing screens keep observing, which is the usual incremental path.

One question does most of the work in this topic: **is this a continuous fact about the current screen, or a thing that should happen exactly once?**

A loading spinner while a password check runs is **state**. It belongs in a StateFlow, seeded with an initial value, always readable through .value, conflated so a repeated identical state costs nothing.

A "wrong password, try again" message is an **event**. It should fire once, and it should not be re-delivered when the device rotates.

That single distinction explains every rule here:

- StateFlow demands an initial value because state always exists. SharedFlow does not because an event stream can be empty. - StateFlow conflates because only the current state matters, which is exactly why it is wrong for events: emit the same error twice and the second one disappears. - SharedFlow has replay because a late subscriber may or may not want history, and that is a per-event-stream decision rather than a universal one. - .value exists on one and not the other because "the current event" is not a meaningful idea.

And the honest caveat to finish on: SharedFlow is an imperfect fit for events at either setting. replay = 0 drops events with no subscriber; replay = 1 re-delivers on rotation. A Channel exposed through receiveAsFlow() buffers and delivers exactly once, at the cost of supporting a single collector, and it is the better answer for a one-off event consumed by one screen.

Where the collection happens, and how it stops when the screen does, is the neighbouring topic.

Back to StateFlow & SharedFlow