Advanced Kotlin Flow Types: StateFlow vs. SharedFlow

StateFlow and SharedFlow are both hot flows built on top of Kotlin Coroutines, but they solve different problems. This guide goes beyond the basics and focuses on advanced usage, subtle differences, and patterns that matter in real-world architectures like MVVM and MVI.

ADVANCED OVERVIEW

StateFlow vs. SharedFlow in one mental model

Both StateFlow and SharedFlow are hot flows: they keep emitting regardless of active collectors. The key difference:

  • StateFlow: A state holder with exactly one current value.
  • SharedFlow: A configurable event stream with optional replay and buffering.

You can think of StateFlow as a specialized SharedFlow with replay = 1, a non-null value property, and strong guarantees about conflation and distinctness. SharedFlow is the more general primitive that can model events, channels, and even broadcast queues.

STATEFLOW

StateFlow: single source of truth for UI state

StateFlow is ideal when you need a single, always-available snapshot of state: UI models, form data, or derived domain state. It is eager: it always has a value and never completes.

Key properties

  • Always has a value: exposed via stateFlow.value.
  • Conflated: fast updates collapse into the latest value.
  • Distinct by reference: re-emitting the same instance still triggers collectors.
  • Hot: producers run regardless of collectors.
ViewModel: StateFlow as UI state
data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

class ItemsViewModel(
    private val repository: ItemsRepository
) : ViewModel() {

    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state

    init {
        observeItems()
    }

    private fun observeItems() {
        viewModelScope.launch {
            repository.itemsFlow()
                .onStart { _state.update { it.copy(isLoading = true) } }
                .catch { e ->
                    _state.update {
                        it.copy(
                            isLoading = false,
                            error = e.message ?: "Unknown error"
                        )
                    }
                }
                .collect { items ->
                    _state.update {
                        it.copy(
                            isLoading = false,
                            items = items,
                            error = null
                        )
                    }
                }
        }
    }

    fun refresh() {
        viewModelScope.launch {
            repository.refresh()
        }
    }
}

Advanced patterns with StateFlow

  • Derived state: use map + stateIn to create computed StateFlows from other flows.
  • Scoped sharing: use stateIn(scope, started, initialValue) to convert cold flows into hot, lifecycle-aware state.
  • Backpressure-friendly UI: because it is conflated, rapid upstream updates won’t overwhelm the UI.
Derived StateFlow using stateIn
class DashboardViewModel(
    private val repository: DashboardRepository
) : ViewModel() {

    private val rawMetrics: Flow<Metrics> =
        repository.metricsStream() // cold flow from data layer

    val uiState: StateFlow<DashboardUiState> =
        rawMetrics
            .map { metrics -> DashboardUiState.from(metrics) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = DashboardUiState.Loading
            )
}

Tip: prefer stateIn over manually wiring a MutableStateFlow when you’re just adapting an existing flow into UI state.

SHAREDFLOW

SharedFlow: events, broadcasts, and custom buffers

SharedFlow is a general-purpose hot flow that can replay values to new collectors and buffer emissions. It is perfect for one-off events (navigation, toasts, dialogs) and for multi-consumer streams where you need more control than StateFlow offers.

Key configuration knobs

  • replay: how many past values new collectors receive.
  • extraBufferCapacity: how many values can be buffered beyond replay.
  • onBufferOverflow: what to do when the buffer is full (DROP_OLDEST, DROP_LATEST, SUSPEND).
SharedFlow for one-off UI events
sealed interface UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent
    data object NavigateToLogin : UiEvent
}

class AuthViewModel(
    private val authRepository: AuthRepository
) : ViewModel() {

    private val _events = MutableSharedFlow<UiEvent>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val events: SharedFlow<UiEvent> = _events

    fun onLogoutClicked() {
        viewModelScope.launch {
            authRepository.logout()
            _events.emit(UiEvent.NavigateToLogin)
        }
    }

    fun onLoginFailed(message: String) {
        viewModelScope.tryEmit(UiEvent.ShowSnackbar(message))
    }
}

Here, replay = 0 ensures events are not re-delivered to new collectors (no “stale navigation” after rotation), while extraBufferCapacity = 1 allows a single event to be buffered if the collector is briefly busy.

SharedFlow as a multi-consumer bus

Because SharedFlow supports multiple collectors, it can act as a lightweight event bus inside a feature module—without the global, hard-to-test downsides of a traditional event bus.

SharedFlow as a scoped event bus
class AnalyticsBus {

    private val _events = MutableSharedFlow<AnalyticsEvent>(
        replay = 0,
        extraBufferCapacity = 64,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )

    val events: SharedFlow<AnalyticsEvent> = _events

    fun track(event: AnalyticsEvent) {
        _events.tryEmit(event)
    }
}

// Consumers (e.g., in a singleton or feature scope)
class AnalyticsLogger(bus: AnalyticsBus, scope: CoroutineScope) {

    init {
        scope.launch {
            bus.events.collect { event ->
                logToBackend(event)
            }
        }
    }
}

Warning: avoid using a global SharedFlow as a cross-app event bus; keep it scoped to a feature or module to preserve testability and separation of concerns.

HOT FROM COLD

shareIn vs. stateIn: making cold flows hot

Both shareIn and stateIn convert a cold Flow into a hot flow tied to a coroutine scope. The difference is the resulting type and semantics:

  • stateIn: produces a StateFlow with a single current value.
  • shareIn: produces a SharedFlow with configurable replay and buffering.
Cold → hot using shareIn
class PricesRepository(
    private val api: PricesApi,
    externalScope: CoroutineScope
) {

    // Cold flow: each collector would trigger its own network stream
    private val rawPrices: Flow<PriceUpdate> =
        api.pricesStream()

    // Hot shared flow: single upstream subscription, multiple collectors
    val sharedPrices: SharedFlow<PriceUpdate> =
        rawPrices.shareIn(
            scope = externalScope,
            started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 10_000),
            replay = 0
        )
}

Use stateIn when you care about the latest value as state; use shareIn when you care about the stream of events and want to control replay and buffering.

DECISION GUIDE

When to use StateFlow vs. SharedFlow

Rule of thumb

  • Use StateFlow for: long-lived, observable state that always has a value (UI models, feature state).
  • Use SharedFlow for: one-off events, multi-consumer streams, or when you need custom replay/buffer behavior.

Common pitfalls

  • Pitfall: using StateFlow for navigation or snackbars. New collectors will immediately see the “last event” as if it just happened.
  • Pitfall: using SharedFlow with replay > 0 for events without understanding that new collectors will re-consume old events.
  • Subtle bug: relying on StateFlow to filter out identical values. It does not perform distinctUntilChanged by default.
Combining StateFlow + SharedFlow in MVI
data class CounterState(
    val value: Int = 0
)

sealed interface CounterEvent {
    data class ShowToast(val message: String) : CounterEvent
}

class CounterViewModel : ViewModel() {

    private val _state = MutableStateFlow(CounterState())
    val state: StateFlow<CounterState> = _state

    private val _events = MutableSharedFlow<CounterEvent>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val events: SharedFlow<CounterEvent> = _events

    fun increment() {
        val newValue = _state.value.value + 1
        _state.value = CounterState(newValue)

        if (newValue % 10 == 0) {
            _events.tryEmit(
                CounterEvent.ShowToast("Nice! You reached $newValue.")
            )
        }
    }
}

This pattern—StateFlow for state + SharedFlow for events—is a solid default for MVVM/MVI architectures.

TESTING & CONCURRENCY

Testing, concurrency, and performance notes

Testing flows

  • StateFlow: you can assert on state.value directly, or collect using runTest and first()/take().
  • SharedFlow: use replay in tests or libraries like Turbine to assert on emitted events.

Concurrency and performance

  • StateFlow updates: prefer update { } to avoid race conditions when multiple coroutines modify the same state.
  • SharedFlow buffers: choose onBufferOverflow carefully; SUSPEND can backpressure producers, while DROP_OLDEST or DROP_LATEST can lose data.
  • Hot flows and leaks: always tie shareIn / stateIn to a well-scoped CoroutineScope (e.g., ViewModel or feature scope).

Rule: if you can phrase it as “what is the current state?”, use StateFlow. If you phrase it as “what happened?”, use SharedFlow.