In the previous two posts, we covered cold Flows — streams that start fresh for every collector. But in real Android apps, you often need streams that are always active, hold a current value, or broadcast events to multiple collectors at once. That’s where StateFlow and SharedFlow come in — Kotlin’s hot stream primitives. StateFlow is your go-to for UI state. SharedFlow is your go-to for one-time events. This guide breaks down how each works, when to use which, and the real patterns that matter in Android development.


Hot vs Cold — Quick Recap

Before diving in, let’s be clear about the difference:

// COLD Flow — starts fresh for every collector
val coldFlow = flow {
    println("Fetching data...")
    emit(repository.getData())
}

// Collector 1 triggers "Fetching data..." and gets the result
coldFlow.collect { println(it) }

// Collector 2 triggers "Fetching data..." AGAIN — completely independent
coldFlow.collect { println(it) }


// HOT Flow — exists independently of collectors
val hotFlow = MutableStateFlow("initial")

// Value exists even with zero collectors
println(hotFlow.value)   // "initial"

// Multiple collectors share the same stream
launch { hotFlow.collect { println("Collector 1: $it") } }
launch { hotFlow.collect { println("Collector 2: $it") } }

hotFlow.value = "updated"   // both collectors receive "updated"

Cold Flows are lazy — they produce values only when collected. Hot Flows are eager — they produce values regardless of whether anyone is listening.


StateFlow

StateFlow is a hot Flow that always holds a current value. Think of it as an observable variable — it stores a single state and notifies collectors whenever that state changes.

// MutableStateFlow — read and write
val _counter = MutableStateFlow(0)   // must provide initial value

// StateFlow — read-only (expose to UI)
val counter: StateFlow<Int> = _counter

// Update the value
_counter.value = 1
_counter.value = 2

// Read the current value synchronously
println(_counter.value)   // 2

// Collect changes reactively
viewModelScope.launch {
    counter.collect { value ->
        println("Counter: $value")   // prints current value immediately, then updates
    }
}

Key characteristics of StateFlow

val state = MutableStateFlow("A")

// 1. Always has a value — no null, no empty
println(state.value)   // "A" — available immediately

// 2. Replays the latest value to new collectors
launch {
    delay(1000)
    state.collect { println(it) }   // immediately receives current value
}
state.value = "B"   // collector gets "B" when it starts (or later value)

// 3. Conflated — only the latest value matters
state.value = "C"
state.value = "D"
state.value = "E"
// A slow collector might only see "E" — intermediate values are dropped

// 4. distinctUntilChanged built-in — skips duplicate values
state.value = "E"   // same value — collectors are NOT notified
state.value = "F"   // different value — collectors ARE notified

// 5. Never completes — collectors suspend forever waiting for updates
// (unlike cold Flow which completes when the block finishes)

StateFlow in ViewModel — the standard pattern

class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {

    // Private mutable — only ViewModel can modify
    private val _uiState = MutableStateFlow<ArticleUiState>(ArticleUiState.Loading)

    // Public read-only — UI observes this
    val uiState: StateFlow<ArticleUiState> = _uiState

    init {
        loadArticles()
    }

    private fun loadArticles() {
        viewModelScope.launch {
            try {
                val articles = repository.getArticles()
                _uiState.value = ArticleUiState.Success(articles)
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _uiState.value = ArticleUiState.Error(e.message ?: "Unknown error")
            }
        }
    }

    fun retry() {
        _uiState.value = ArticleUiState.Loading
        loadArticles()
    }
}

sealed interface ArticleUiState {
    data object Loading : ArticleUiState
    data class Success(val articles: List<Article>) : ArticleUiState
    data class Error(val message: String) : ArticleUiState
}

update {} — thread-safe state modification

// ❌ Not thread-safe — read and write are separate operations
_uiState.value = _uiState.value.copy(isLoading = true)
// Another coroutine could change value between read and write

// ✅ Thread-safe atomic update
_uiState.update { currentState ->
    currentState.copy(isLoading = true)
}
// update {} reads and writes atomically — no race conditions

// Real example — toggle a favourite
fun toggleFavourite(articleId: String) {
    _uiState.update { state ->
        if (state is ArticleUiState.Success) {
            val updated = state.articles.map { article ->
                if (article.id == articleId) {
                    article.copy(isFavourite = !article.isFavourite)
                } else article
            }
            state.copy(articles = updated)
        } else state
    }
}

SharedFlow

SharedFlow is a hot Flow that broadcasts values to multiple collectors without holding a current state. It’s designed for events — things that happen once and shouldn’t be replayed or conflated.

// MutableSharedFlow — emit events
val _events = MutableSharedFlow<UiEvent>()

// SharedFlow — read-only (expose to UI)
val events: SharedFlow<UiEvent> = _events

// Emit an event (suspending function)
viewModelScope.launch {
    _events.emit(UiEvent.ShowSnackbar("Article saved"))
}

// Collect events
viewModelScope.launch {
    events.collect { event ->
        when (event) {
            is UiEvent.ShowSnackbar -> showSnackbar(event.message)
            is UiEvent.NavigateTo -> navigate(event.route)
        }
    }
}

Key characteristics of SharedFlow

// 1. No initial value required
val events = MutableSharedFlow<String>()   // no default value

// 2. Default: no replay (new collectors miss past events)
val noReplay = MutableSharedFlow<String>()          // replay = 0 (default)
noReplay.emit("Event 1")
noReplay.collect { }   // does NOT receive "Event 1"

// 3. Configurable replay
val withReplay = MutableSharedFlow<String>(replay = 1)   // replays last 1
withReplay.emit("Event 1")
withReplay.emit("Event 2")
withReplay.collect { }   // receives "Event 2" immediately (last 1 replayed)

// 4. Does NOT skip duplicates (unlike StateFlow)
val events2 = MutableSharedFlow<String>()
events2.emit("click")
events2.emit("click")   // collectors receive "click" TWICE

// 5. Configurable buffer for slow collectors
val buffered = MutableSharedFlow<String>(
    replay = 0,
    extraBufferCapacity = 64,                // buffer up to 64 values
    onBufferOverflow = BufferOverflow.DROP_OLDEST   // drop oldest if full
)

SharedFlow for one-time events

class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {

    private val _uiState = MutableStateFlow<ArticleUiState>(ArticleUiState.Loading)
    val uiState: StateFlow<ArticleUiState> = _uiState

    // Events — should be consumed once, not replayed
    private val _events = MutableSharedFlow<UiEvent>()
    val events: SharedFlow<UiEvent> = _events

    fun saveArticle(article: Article) {
        viewModelScope.launch {
            try {
                repository.save(article)
                _events.emit(UiEvent.ShowSnackbar("Article saved"))
                _events.emit(UiEvent.NavigateBack)
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _events.emit(UiEvent.ShowSnackbar("Save failed: ${e.message}"))
            }
        }
    }

    fun deleteArticle(id: String) {
        viewModelScope.launch {
            repository.delete(id)
            _events.emit(UiEvent.ShowSnackbar("Article deleted"))
        }
    }
}

sealed interface UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent
    data class NavigateTo(val route: String) : UiEvent
    data object NavigateBack : UiEvent
}

StateFlow vs SharedFlow — Side by Side

// ┌──────────────────────┬──────────────────────┬──────────────────────┐
// │                      │     StateFlow         │     SharedFlow        │
// ├──────────────────────┼──────────────────────┼──────────────────────┤
// │ Initial value        │ Required              │ Not required          │
// │ Current value        │ .value accessible     │ No .value property    │
// │ Replay               │ Always 1 (latest)     │ Configurable (0..N)   │
// │ Duplicate emissions  │ Skipped (conflated)   │ All delivered          │
// │ Best for             │ UI State              │ One-time events        │
// │ Comparable to        │ LiveData              │ SingleLiveEvent        │
// │ Completes?           │ Never                 │ Never                  │
// └──────────────────────┴──────────────────────┴──────────────────────┘

Rule of thumb

// Use StateFlow when:
// - UI needs to display current state (loading, content, error)
// - New collectors should immediately see the latest state
// - Duplicate values are meaningless (screen already shows "loading")

// Use SharedFlow when:
// - Events should be consumed once (snackbar, navigation, toast)
// - Duplicate events matter (user clicked "save" twice = two saves)
// - You don't need a "current value" concept

Converting Cold Flow to Hot Flow

stateIn — cold Flow to StateFlow

class ArticleViewModel(repository: ArticleRepository) : ViewModel() {

    // Convert a cold database Flow to a hot StateFlow
    val articles: StateFlow<List<Article>> = repository.getArticles()   // cold Flow
        .map { it.filter { article -> article.isPublished } }
        .catch { emit(emptyList()) }
        .stateIn(
            scope = viewModelScope,           // keeps the Flow alive
            started = SharingStarted.WhileSubscribed(5000),   // sharing strategy
            initialValue = emptyList()        // value before first emission
        )
}

SharingStarted strategies

// Eagerly — starts immediately, never stops
// Use for: data that should always be ready
stateIn(viewModelScope, SharingStarted.Eagerly, initial)

// Lazily — starts on first collector, never stops
// Use for: data needed eventually but not immediately
stateIn(viewModelScope, SharingStarted.Lazily, initial)

// WhileSubscribed — starts on first collector, stops when last one leaves
// Use for: most Android UI cases (saves resources when app is in background)
stateIn(
    viewModelScope,
    SharingStarted.WhileSubscribed(
        stopTimeoutMillis = 5000,   // wait 5s after last collector before stopping
        replayExpirationMillis = 0  // keep last value after stopping (default)
    ),
    initial
)

// WhileSubscribed(5000) is the recommended default for Android —
// 5s timeout survives configuration changes (screen rotation)
// without keeping resources alive when app is truly in background

shareIn — cold Flow to SharedFlow

// Share a cold Flow among multiple collectors
val notifications: SharedFlow<Notification> = repository.getNotifications()
    .shareIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(),
        replay = 0   // don't replay old notifications
    )

// With replay — new collectors get the last N values
val recentPrices: SharedFlow<Price> = priceService.getPriceUpdates()
    .shareIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        replay = 1   // new collectors get the last price immediately
    )

Collecting in the UI Layer

Fragment — lifecycle-aware collection

class ArticleFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Collect StateFlow — UI state
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    when (state) {
                        is ArticleUiState.Loading -> showLoading()
                        is ArticleUiState.Success -> showArticles(state.articles)
                        is ArticleUiState.Error -> showError(state.message)
                    }
                }
            }
        }

        // Collect SharedFlow — one-time events
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.events.collect { event ->
                    when (event) {
                        is UiEvent.ShowSnackbar -> {
                            Snackbar.make(view, event.message, Snackbar.LENGTH_SHORT).show()
                        }
                        is UiEvent.NavigateBack -> findNavController().popBackStack()
                        is UiEvent.NavigateTo -> findNavController().navigate(event.route)
                    }
                }
            }
        }
    }
}

Compose — collecting StateFlow

@Composable
fun ArticleScreen(viewModel: ArticleViewModel = viewModel()) {

    // collectAsStateWithLifecycle — lifecycle-aware, recommended
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when (val state = uiState) {
        is ArticleUiState.Loading -> LoadingIndicator()
        is ArticleUiState.Success -> ArticleList(state.articles)
        is ArticleUiState.Error -> ErrorMessage(state.message)
    }

    // Collecting SharedFlow events in Compose
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
                is UiEvent.NavigateBack -> navController.popBackStack()
                is UiEvent.NavigateTo -> navController.navigate(event.route)
            }
        }
    }
}

Collecting multiple Flows concurrently

// ❌ Second collect never runs — first collect suspends forever
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { /* ... */ }      // suspends forever
        viewModel.events.collect { /* ... */ }        // never reached!
    }
}

// ✅ Launch each collection in a separate coroutine
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        launch { viewModel.uiState.collect { /* ... */ } }
        launch { viewModel.events.collect { /* ... */ } }
    }
}

Real Android Patterns

Complete ViewModel with StateFlow + SharedFlow

class ArticleDetailViewModel(
    private val articleId: String,
    private val repository: ArticleRepository,
    private val bookmarkRepository: BookmarkRepository
) : ViewModel() {

    // UI State — what the screen displays
    private val _uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
    val uiState: StateFlow<DetailUiState> = _uiState

    // Events — one-time actions
    private val _events = MutableSharedFlow<DetailEvent>()
    val events: SharedFlow<DetailEvent> = _events

    init {
        loadArticle()
    }

    private fun loadArticle() {
        viewModelScope.launch {
            try {
                val article = repository.getArticle(articleId)
                val isBookmarked = bookmarkRepository.isBookmarked(articleId)
                _uiState.value = DetailUiState.Success(
                    article = article,
                    isBookmarked = isBookmarked
                )
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _uiState.value = DetailUiState.Error(e.message ?: "Failed to load")
            }
        }
    }

    fun toggleBookmark() {
        _uiState.update { state ->
            if (state is DetailUiState.Success) {
                state.copy(isBookmarked = !state.isBookmarked)
            } else state
        }

        viewModelScope.launch {
            try {
                val current = _uiState.value
                if (current is DetailUiState.Success) {
                    if (current.isBookmarked) {
                        bookmarkRepository.bookmark(articleId)
                        _events.emit(DetailEvent.ShowSnackbar("Bookmarked"))
                    } else {
                        bookmarkRepository.removeBookmark(articleId)
                        _events.emit(DetailEvent.ShowSnackbar("Bookmark removed"))
                    }
                }
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                // Revert optimistic update
                _uiState.update { state ->
                    if (state is DetailUiState.Success) {
                        state.copy(isBookmarked = !state.isBookmarked)
                    } else state
                }
                _events.emit(DetailEvent.ShowSnackbar("Failed: ${e.message}"))
            }
        }
    }

    fun shareArticle() {
        val state = _uiState.value
        if (state is DetailUiState.Success) {
            viewModelScope.launch {
                _events.emit(DetailEvent.ShareArticle(state.article.url))
            }
        }
    }
}

sealed interface DetailUiState {
    data object Loading : DetailUiState
    data class Success(val article: Article, val isBookmarked: Boolean) : DetailUiState
    data class Error(val message: String) : DetailUiState
}

sealed interface DetailEvent {
    data class ShowSnackbar(val message: String) : DetailEvent
    data class ShareArticle(val url: String) : DetailEvent
}

Multiple StateFlows combined for complex UI

class HomeViewModel(
    private val userRepo: UserRepository,
    private val feedRepo: FeedRepository,
    private val notificationRepo: NotificationRepository
) : ViewModel() {

    private val _refreshing = MutableStateFlow(false)

    val uiState: StateFlow<HomeUiState> = combine(
        userRepo.currentUser(),
        feedRepo.getFeed(),
        notificationRepo.unreadCount(),
        _refreshing
    ) { user, feed, unreadCount, refreshing ->
        HomeUiState(
            userName = user.displayName,
            avatarUrl = user.avatarUrl,
            feedItems = feed.map { it.toUiModel() },
            unreadNotifications = unreadCount,
            isRefreshing = refreshing
        )
    }
    .catch { e ->
        emit(HomeUiState(error = e.message))
    }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = HomeUiState(isLoading = true)
    )

    fun refresh() {
        viewModelScope.launch {
            _refreshing.value = true
            try {
                feedRepo.refresh()
            } finally {
                _refreshing.value = false
            }
        }
    }
}

SharedFlow as an event bus between ViewModels

// Shared event bus — singleton
object AppEventBus {
    private val _events = MutableSharedFlow<AppEvent>(extraBufferCapacity = 64)
    val events: SharedFlow<AppEvent> = _events

    suspend fun send(event: AppEvent) {
        _events.emit(event)
    }
}

sealed interface AppEvent {
    data class UserLoggedOut(val reason: String) : AppEvent
    data object ThemeChanged : AppEvent
    data class DeepLink(val uri: String) : AppEvent
}

// Producer — any ViewModel can send events
class AuthViewModel : ViewModel() {
    fun logout() {
        viewModelScope.launch {
            authRepository.logout()
            AppEventBus.send(AppEvent.UserLoggedOut("User requested"))
        }
    }
}

// Consumer — any ViewModel can receive events
class CartViewModel : ViewModel() {
    init {
        viewModelScope.launch {
            AppEventBus.events.collect { event ->
                when (event) {
                    is AppEvent.UserLoggedOut -> clearCart()
                    else -> { /* ignore */ }
                }
            }
        }
    }
}

Common Mistakes to Avoid

Mistake 1: Using StateFlow for one-time events

// ❌ StateFlow replays the last value — snackbar shows again on rotation
private val _event = MutableStateFlow<String?>(null)

fun save() {
    _event.value = "Saved!"   // new collectors see this on rotation!
}

// ✅ Use SharedFlow for events — no replay by default
private val _event = MutableSharedFlow<String>()

fun save() {
    viewModelScope.launch {
        _event.emit("Saved!")   // consumed once, not replayed
    }
}

Mistake 2: Collecting multiple Flows sequentially instead of concurrently

// ❌ Second Flow is never collected
repeatOnLifecycle(Lifecycle.State.STARTED) {
    viewModel.uiState.collect { /* ... */ }   // blocks forever
    viewModel.events.collect { /* ... */ }     // never reached
}

// ✅ Launch separately
repeatOnLifecycle(Lifecycle.State.STARTED) {
    launch { viewModel.uiState.collect { /* ... */ } }
    launch { viewModel.events.collect { /* ... */ } }
}

Mistake 3: Exposing MutableStateFlow to the UI

// ❌ UI can modify state directly — breaks unidirectional data flow
class MyViewModel : ViewModel() {
    val uiState = MutableStateFlow(UiState.Loading)   // anyone can write!
}

// In Fragment:
viewModel.uiState.value = UiState.Success(fakeData)   // oops

// ✅ Expose read-only StateFlow
class MyViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState   // read-only
}

Mistake 4: Forgetting that StateFlow skips duplicate values

// ❌ Collectors won't be notified — same value
data class FormState(val name: String, val errors: List<String>)

_formState.value = FormState("Alice", listOf("Name too short"))
_formState.value = FormState("Alice", listOf("Name too short"))
// Second emission is silently ignored — same data class equals()

// ✅ If you need every emission, use SharedFlow
// Or add a unique field like a timestamp or counter
data class FormState(
    val name: String,
    val errors: List<String>,
    val updateId: Long = System.currentTimeMillis()   // forces uniqueness
)

Mistake 5: Using WhileSubscribed(0) — losing state on rotation

// ❌ Stops immediately when collector is gone — restarts on rotation
stateIn(viewModelScope, SharingStarted.WhileSubscribed(0), initial)
// Screen rotation = collector unsubscribes briefly = upstream restarts!

// ✅ Add a timeout to survive configuration changes
stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initial)
// 5 seconds is enough for rotation — upstream keeps running

Summary

  • StateFlow is a hot Flow that always holds a current value — use it for UI state
  • SharedFlow is a hot Flow that broadcasts events without holding state — use it for one-time events
  • StateFlow requires an initial value, is conflated (latest value only), and skips duplicates
  • SharedFlow has no initial value, configurable replay, and delivers every emission including duplicates
  • Use update {} on MutableStateFlow for thread-safe state modification
  • Convert cold Flow to StateFlow with stateIn and to SharedFlow with shareIn
  • SharingStarted.WhileSubscribed(5000) is the recommended strategy for most Android UI use cases
  • Always expose read-only StateFlow / SharedFlow to the UI — keep Mutable variants private
  • Collect with repeatOnLifecycle in Fragments or collectAsStateWithLifecycle in Compose
  • When collecting multiple Flows, launch each in a separate coroutine — collect suspends forever
  • StateFlow replays the latest value to new collectors — don’t use it for one-time events
  • SharedFlow with extraBufferCapacity prevents emit from suspending when collectors are slow
  • Use WhileSubscribed(5000) — not WhileSubscribed(0) — to survive screen rotation

StateFlow and SharedFlow complete the reactive toolkit in Kotlin. StateFlow replaces LiveData for observable state. SharedFlow replaces SingleLiveEvent for one-time signals. Combined with cold Flow for data pipelines and operators for transformations, you now have everything you need to build fully reactive Android apps without ever reaching for RxJava.

Happy coding!