StateFlow vs. SharedFlow: Thinking in “State” vs. “Event”

We’ve all been there. You’re building a new feature, everything works perfectly. You tap a button, the profile saves, a “Success!” toast message appears. Life is good.

Then you rotate the screen.

And the toast message appears again.

Or maybe you navigate to a details screen, rotate, and suddenly you’re navigating to that same details screen a second time, totally wrecking your back stack.

What is going on?

Honestly, this is a rite of passage for Android developers. It’s the classic, painful symptom of confusing “state” with an “event.” You’re not alone in this; this problem is exactly why StateFlow and SharedFlow exist. And once you see the difference, you can’t unsee it.

The “A-ha!” Moment: Hot vs. Cold Streams

Before we even type StateFlow, we need to get one concept locked in: hot vs. cold streams. This is the whole magic trick. The base flow { ... } builder you’ve probably used? That’s cold.

  • Cold Stream (Netflix): Think of Netflix. You press play, it starts a new stream just for you, from the beginning. Every person who presses “play” gets their own, private movie. The producer (the code inside flow { ... }) runs for every single collector.
  • Hot Stream (Live TV): This is a live broadcast. It’s on whether you’re watching or not. When you tune in, you join the broadcast in progress. You don’t get your own personal show. All listeners tune into the same shared broadcast.

For UI, we almost always need a Live TV. We need one “broadcast” of the current screen state that all parts of our UI (like a recreated Activity) can tune into.

This is where StateFlow and SharedFlow live. They are both hot streams.

Meet StateFlow: The Town Crier Who Always Knows the Current State

StateFlow is your new best friend for holding state.

Think of it as your app’s “town crier.” His only job is to know the single latest piece of news and shout it to anyone who walks into the town square.

StateFlow is defined by a few key features:

  1. It must have an initial value (e.g., UiState.Loading). Your screen can’t exist in “no state.”
  2. It only keeps the last value. StateFlow conflates emissions when collectors can’t keep up. If state updates rapidly (A → B → C) and the collector is slow, it might skip B and only process A then C. This prevents UI lag. Additionally, StateFlow uses distinctUntilChanged(), meaning duplicate consecutive values (A → A → A) are filtered out and only emit once.
  3. It replays that last value to new subscribers.

That last point is what fixes half our problem. When your screen rotates, the new UI (the “new villager”) just walks up to the StateFlow (the “town crier”) and asks, “What’s the news?” It instantly gets the exact last state (e.g., UiState.Success(data)) and can render itself perfectly.

No more lost state on rotation. Beautiful.

Where It All Goes Wrong: The StateFlow-for-Events Trap

So, if StateFlow is so great, why did our toast message show up twice?

This is the single most common mistake: trying to use StateFlow to manage one-time *events*.

It’s so tempting. You probably wrote code like this in your ViewModel:

// The "trap" code. Don't do this!
private val _showToastEvent = MutableStateFlow<String?>(null)
val showToastEvent: StateFlow<String?> = _showToastEvent

fun onSaveClicked() {
    // ... save data ...
    _showToastEvent.value = "Profile Saved!"
}

fun onToastShown() {
    _showToastEvent.value = null
}

This seems smart. The UI collects the flow. When it sees a non-null message, it shows the toast and then calls onToastShown() to reset it.

But you just created a race condition. What happens on rotation?

  1. User taps “Save.” StateFlow value becomes "Profile Saved!".
  2. The UI sees the message, starts showing the toast.
  3. The user rotates the phone before the UI can call onToastShown().
  4. The old UI is destroyed.
  5. A new UI is created. It subscribes to the StateFlow.
  6. StateFlow (being a helpful town crier) says, “Oh, a new listener! Here’s the last news I had!” and it helpfully serves up "Profile Saved!"again.

Boom. Double toast. Same thing for your navigation bug.

The Real Solution: SharedFlow, the Event Messenger

If StateFlow is for “what is the state now?”, then SharedFlow is for “what just happened?”.

It’s for one-time, fire-and-forget events.

Think of SharedFlow as a live radio announcer.

  • By default, it has no replay (replay = 0).
  • If you’re not listening at the exact moment the announcement is made, you miss it.

This is exactly what we want for our toast message and navigation! The new, rotated UI tunes in, but SharedFlow has nothing to replay. The old event is gone, as it should be.

Here’s the right way to handle those events in your ViewModel:

// Inside the ViewModel
// This is for *one-time events*.
// It has no initial value and (by default) no replay.
// IMPORTANT: Use extraBufferCapacity to handle events when no collectors are active
private val _events = MutableSharedFlow<UiEvent>(
    extraBufferCapacity = 1 // Buffers events if no collectors are listening
)
val events: SharedFlow<UiEvent> = _events.asSharedFlow()

fun onSaveClicked() {
    // ... save data ...
    viewModelScope.launch {
        // Fire a "fire-and-forget" event
        // Note: emit() suspends if no collectors. Use tryEmit() for non-suspending alternative
        _events.emit(UiEvent.ShowToast("Profile Saved!"))
    }
}

// A sealed class is a great way to handle all your events
sealed class UiEvent {
    data class ShowToast(val message: String) : UiEvent()
    data class Navigate(val route: String) : UiEvent()
}

Critical Note on SharedFlow Configuration:

Without extraBufferCapacity, the default MutableSharedFlow() will suspend the emit() call if there are no active collectors. This can cause:

  • Events to pile up in memory if the UI isn’t collecting yet
  • Unexpected suspensions in your ViewModel logic

Two safe approaches:

  1. Use extraBufferCapacity (recommended for most cases):
private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1)
  1. Use tryEmit() for non-suspending emission:
fun onSaveClicked() {
    // ... save data ...
    _events.tryEmit(UiEvent.ShowToast("Profile Saved!"))
}

In your UI, you’d collect this events flow. It will receive the ShowToast event once. When the screen rotates, the new UI subscribes, but SharedFlow has nothing to replay. Problem solved.

This is the core rule: Use StateFlow for State. Use SharedFlow for Events.

Collecting Flows Safely in the UI

Here’s a critical piece that often gets overlooked: how you collect flows in your UI matters.

❌ WRONG – This causes leaks and background processing:

// In your Activity or Fragment
lifecycleScope.launch {
    viewModel.events.collect { event -> 
        when (event) {
            is UiEvent.ShowToast -> showToast(event.message)
            is UiEvent.Navigate -> navigate(event.route)
        }
    }
}

The problem? This collection continues even when your app is in the background! You might show toasts when the user can’t see them, or worse, trigger navigation in an invalid state.

✅ CORRECT – Lifecycle-aware collection:

// In your Activity or Fragment
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.events.collect { event ->
            when (event) {
                is UiEvent.ShowToast -> showToast(event.message)
                is UiEvent.Navigate -> navigate(event.route)
            }
        }
    }
}

For Jetpack Compose:

@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // For events
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is UiEvent.ShowToast -> /* show toast */
                is UiEvent.Navigate -> /* navigate */
            }
        }
    }

    // Rest of your composable...
}

The repeatOnLifecycle pattern ensures collections are cancelled when your UI goes to the background and restarted when it comes back to the foreground. This prevents resource leaks and inappropriate UI updates.

The Secret Sauce: Combining StateFlow with Sealed Classes

Okay, here’s the pro-tip that ties this all together. How do we manage Loading, Success, and Error states?

Please, don’t use three different StateFlow<Boolean>s. That way lies madness and “state soup.”

We use one StateFlow with a sealed class. This is, in my experience, the single most robust UI state pattern on Android. It makes impossible states impossible.

You define your entire screen state as a sealed class:

// 1. Define all possible states for your screen
sealed class UserProfileUiState {
    object Loading : UserProfileUiState()
    data class Success(val user: User) : UserProfileUiState()
    data class Error(val message: String) : UserProfileUiState()
}

Then, your ViewModel holds just one StateFlow of that type:

// 2. The ViewModel holds ONE StateFlow for the entire screen
private val _uiState = MutableStateFlow<UserProfileUiState>(UserProfileUiState.Loading)
val uiState: StateFlow<UserProfileUiState> = _uiState.asStateFlow()

fun fetchUser() {
    _uiState.value = UserProfileUiState.Loading
    viewModelScope.launch {
        try {
            val user = repository.fetchUserData()
            _uiState.value = UserProfileUiState.Success(user)
        } catch (e: Exception) {
            // On failure, update the state with an error message
            _uiState.value = UserProfileUiState.Error("Failed to load user profile.")
        }
    }
}

Now your Composable UI just collects this one flow. And the best part? The when statement is exhaustive. The compiler forces you to handle all three states. No more “what if I’m loading and have an error at the same time?” bugs.

It’s clean, type-safe, and resilient to rotation.

The “One More Thing” That Bites Everyone

I’ve gotta admit, this one is a brutal bug to find the first time. StateFlow only works if your state is immutable.

What does that mean? If your User class has a var name: String, and you try to do this:

_uiState.value.user.name = "New Name"

Your UI will not update.

Why? StateFlow‘s distinctUntilChanged check looks at the object reference. As far as it’s concerned, you didn’t give it a new state object; you just scribbled on the old one. It’s the same UserProfileUiState.Success object it had before, so it doesn’t emit an update.

The Golden Rule: Always use data class with val properties.

When you need to change the state, you must create a new copy of the object using the .copy() method that data class gives you for free.

// The WRONG way (mutation)
// _uiState.value.user.name = "Bob" // Fails!

// The RIGHT way (replacement)
val currentState = _uiState.value as UserProfileUiState.Success
val updatedUser = currentState.user.copy(name = "Bob")
_uiState.value = UserProfileUiState.Success(updatedUser)

This creates a new object. StateFlow sees the new reference, knows the state has changed, and shouts the new state to the UI.

Let’s Wrap This Up

That’s really the core of it. Stop fighting screen rotations and start thinking in terms of “State” vs. “Event.”

  • StateFlow: For State. The current condition of the screen. The town crier. (Use with sealed classes and immutable data classes!)
  • SharedFlow: For Events. One-time actions like toasts and navigation. The radio announcer. (Remember to configure extraBufferCapacity and use lifecycle-aware collection!)

Get this distinction right, and I promise your Android development life will get a lot easier.

What’s the trickiest state vs. event bug you’ve ever had to hunt down? Let’s share some war stories in the comments!

Similar Posts