StateFlow & SharedFlow Explained

KOTLIN › Concurrency

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.

Screen state rarely starts life as a StateFlow. More often it starts as a cold flow, a repository method returning Flow<Data>, and you convert it into something hot for the UI to collect. Two operators do that conversion, and which one you reach for follows the same state-versus-events split.

stateIn produces a StateFlow. Because a StateFlow must always have a current value, stateIn requires you to supply one.

val uiState: StateFlow<UiState> = repository.dataFlow()
    .map { UiState.Success(it) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading
    )

shareIn produces a SharedFlow instead, and needs no initial value, since it's modelling events rather than a current state.

val shared: SharedFlow<Data> = repository.expensiveDataFlow().shareIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(),
    replay = 0
)

Both multicast a single upstream producer across every collector, so the repository's expensive work runs once no matter how many parts of the UI are observing it.

Both stateIn and shareIn take a started parameter that controls when the upstream producer actually runs. SharingStarted.Eagerly starts it immediately and keeps it alive for as long as the scope lives, regardless of how many collectors show up. SharingStarted.WhileSubscribed(stopTimeoutMillis) is demand-driven instead: it starts on the first subscriber and stops stopTimeoutMillis after the last one unsubscribes.

WhileSubscribed(5_000) is the common choice on Android because a configuration change briefly drops the collector count to zero while the old Activity tears down and the new one starts collecting again. Without that five-second grace period, the brief gap would tear down and restart an expensive upstream producer on every rotation. Eagerly ignores subscriber count entirely, which is right for work that should run for the whole scope's life regardless of who's watching, and wrong for anything you only want running while it's actually needed.

Collecting a flow naively in an Activity or View, lifecycleScope.launch { vm.state.collect { render(it) } } with no further wrapping, keeps collecting for as long as lifecycleScope itself is alive. That spans the whole Activity, so it keeps running while the app is backgrounded, wasting work and potentially rendering into views that are no longer on screen.

repeatOnLifecycle(STARTED) fixes this. It launches a fresh coroutine once the lifecycle reaches STARTED, and fully **cancels** it, not merely pauses it, the moment the lifecycle drops to STOPPED. Cancelling tears down the upstream collection entirely, then a return to STARTED restarts it cleanly.

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        vm.state.collect { render(it) }
    }
}

The older launchWhenStarted and launchWhenResumed look similar but only **suspend** the coroutine below their target state, they don't cancel it, so the upstream producer, and anything it's doing such as network polling, keeps running in the background regardless. That gap is exactly why both are deprecated in favor of repeatOnLifecycle.

Compose has the same problem and the same fix, under different names. Plain collectAsState() keeps collecting in the background no matter what the composable's visibility is, the same leak repeatOnLifecycle solves for View-based screens. collectAsStateWithLifecycle(), from the lifecycle-runtime-compose artifact, is the lifecycle-aware replacement: it stops collecting once the composable's lifecycle drops below STARTED and resumes when it returns.

@Composable
fun MyScreen(vm: MyViewModel = viewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    MyContent(state)
}

The underlying rule is identical to the View case: don't keep an upstream StateFlow collection alive, and doing whatever work backs it, while the screen isn't visible.

Two questions do most of the work in this topic, and they're worth having ready for an interview. First: 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 StateFlow, seeded with an initial value, always readable through .value. A 'blank password, try again' failure is an event, it belongs in SharedFlow with replay = 0, so a late collector doesn't get an error re-thrown at it after a rotation.

Second: is the collector actually visible right now? Whether you're in a View with repeatOnLifecycle(STARTED) or in Compose with collectAsStateWithLifecycle(), the goal is the same, cancel the collection, and whatever upstream work backs it, the moment the screen isn't on display, and restart cleanly when it comes back. Get both of those right, state versus event, and lifecycle-aware collection, and you've covered what interviewers are actually probing for when they ask about StateFlow and SharedFlow.

Back to StateFlow & SharedFlow