Kotlin Flow screen state without tangled observers
Learn a clear Kotlin Flow screen state model for loading, retry, and stale data so your UI stays predictable and bug reports map to one source of truth.

Why screen state gets messy
Most Android screens do not break because Flow is hard. They break because the UI collects too many small signals that can drift apart. One StateFlow says loading is true, another error flag comes from a callback, and the list still shows old items from the last success. The screen renders all of them at once, even when that mix makes no sense.
A common setup starts simple: isLoading, isRefreshing, errorMessage, items, maybe isEmpty. After a few feature requests, each flag gets its own update path. One network call flips loading off. A retry button resets an error. A local cache keeps old data on screen. Nobody steps back to ask which full screen states a user can actually see.
That is where Kotlin Flow screen state often turns into observer tangles. The code still compiles. The UI still updates. But the combinations start to lie.
Users then report states like these:
- A spinner shows while stale content stays visible, but nothing says the data is old
- The retry button appears, yet a refresh call is already running
- An error banner disappears, but the failed state still blocks taps
- The empty state flashes before cached data arrives
Retry logic makes this worse when it hides in click handlers, callback branches, or one-off coroutines. You tap retry, but that action does not move the screen through a clear state model. It just toggles a few fields and hopes the UI settles into something sensible.
Stale data causes quiet confusion too. Keeping old content on screen is often the right move. Showing it with no label is not. If the user sees yesterday's list after a failed refresh, the app should say so. Otherwise bug reports come in as strange mixes: "I saw old items, a loading bar, and an error toast." That is not one bug. It is several state paths collapsed into one messy screen.
When bug reports read like impossible combinations, the model is usually the problem, not the observer count.
Start with one screen contract
A screen gets messy when three different observers each think they own the truth. One listens for loading, another for data, and a third for errors. A week later, someone files a bug because the spinner shows on top of old content after retry. The fix usually starts by deleting extra state holders, not adding one more.
For Kotlin Flow screen state, pick one state holder for the whole screen. In practice, that often means one StateFlow<ScreenState> in the ViewModel. If the UI needs to render something, that fact should exist in that state. If the UI cannot draw it from the state, the contract is incomplete.
A simple contract often starts like this:
sealed interface ScreenState {
data object Loading : ScreenState
data class Content(
val items: List<Item>,
val isRefreshing: Boolean,
val isStale: Boolean
) : ScreenState
data class Error(
val message: String,
val canRetry: Boolean
) : ScreenState
}
This does two useful things. First, it tells the UI exactly what it can show. Second, it blocks weird combinations. You cannot accidentally show Loading and Error as separate top-level states at the same time, because the model does not allow it.
Write transitions before you code the flow logic. Keep it plain. Loading can move to Content or Error. Content can move to refreshing Content. Refreshing Content can stay Content with isStale = true if the request fails but old data still exists. That short map will catch many bugs early.
Treat impossible combinations as design errors, not edge cases. If your model allows isRefreshing = true while there is no data and no loading state, stop and rename the states. When names feel awkward, the UI usually feels awkward too.
A good contract also makes bug reports sharper. Instead of "the screen looks wrong after retry," you get something you can act on: "after retry fails, state changes from Content(isRefreshing=true) to Error, but old items should stay visible." That points to one model, one transition, and one fix.
Name the states users can actually see
A clean Kotlin Flow screen state starts with what a person can notice on the screen. If two internal conditions look the same to the user, one state is enough.
Most screens need fewer states than teams expect. The useful split is usually about data presence, loading behavior, and whether the data still feels fresh.
sealed interface ScreenState {
data object InitialLoading : ScreenState
data class Content(val items: List<Item>) : ScreenState
data class Refreshing(val items: List<Item>) : ScreenState
data class ErrorEmpty(val message: String) : ScreenState
data class ErrorWithData(val items: List<Item>, val message: String) : ScreenState
data class Stale(val items: List<Item>) : ScreenState
}
"InitialLoading" means the screen has nothing to show yet. Use it for the first fetch, not for every network call. A full-screen spinner makes sense here because the user has no data at all.
"Content" is the normal state. The screen has data, buttons work, and the person can read or tap without waiting.
"Refreshing" is different. Old data stays visible while a new request runs in the background. That small split removes a common bug: teams often jump back to a blank loading screen during pull-to-refresh, which feels broken even when the request succeeds.
Errors also need two versions. If the request fails before any data arrives, show an empty error state. If cached data already exists, keep that data on screen and show the failure as a message or inline warning. Users usually prefer old results over an empty page.
"Stale" helps when data still works but may be outdated. A weather screen, order list, or dashboard can keep showing the last known data with a refresh hint. That is more honest than pretending everything is current, and less disruptive than blocking the whole screen.
A simple example makes this clear. Imagine a list of invoices. First open: "InitialLoading". Data arrives: "Content". The user pulls to refresh: "Refreshing". The API fails but yesterday's invoices still exist: "ErrorWithData" or "Stale", depending on whether you want a failure message, a freshness warning, or both.
When bug reports come in, the team can point to one state model instead of five observers and three flags fighting each other.
Keep events and side effects out of state
When a screen state carries snackbars, navigation targets, and tap history, the UI starts repeating old actions. A rotation, a new collector, or process restore can replay something that should have happened once. That is how small bugs turn into weird bug reports.
State should answer one question: what can the user see right now? If the user can see it on the screen, it belongs in StateFlow. If it happens once and then disappears, put it somewhere else.
A separate event stream works well for one-off messages. A "Saved" snackbar, a "Session expired" dialog trigger, or a vibration cue should not sit inside your screen state. In Kotlin Flow screen state, those belong in a SharedFlow or similar event channel so a fresh collector does not replay them by accident.
Navigation has the same problem. A field like openDetailsId = 42 looks simple, but it stays there until something clears it. That means the screen can navigate again after recreation. Keep navigation outside the state model and emit it as an event when the user action passes validation.
Retry is easy to model the wrong way. A boolean like shouldRetry = true is sticky and vague. It does not say who asked for retry, when it happened, or whether the app already handled it. Treat retry as an action instead: onRetryClick(). That action starts work, and the state changes to something real, such as loading, content with stale data, or error.
A clean split usually looks like this:
- State: loading spinner, current data, stale badge, empty view, error body
- Events: snackbar, navigation, close screen, open permission dialog
- Actions: refresh, retry, item click, dismiss message
This keeps state honest. It describes the screen, not the last tap. When someone reports, "I tapped retry and still saw old data," you can inspect one state model instead of chasing observers across the screen.
Build the flow step by step
Start with two inputs only: the repository data and a user action that asks for fresh data. Put refresh and retry into one trigger flow, but keep the reason with each trigger. That small detail saves a lot of confusion later, because a retry after an error should not look the same as a pull-to-refresh over visible content.
Keep one state type for the whole screen. A sealed interface or sealed class works well. Each emission should describe what the user can see right now: loading, content, empty, or error. If old data stays on screen during a failed refresh, emit content with flags like isRefreshing = false and isStale = true instead of jumping to a full error screen.
This is where Kotlin Flow screen state gets much easier to debug. You stop asking, "which observer changed the spinner?" and start asking, "which state did the ViewModel emit?" Bug reports point to one model, not five moving parts.
A clean pattern is to let the repository expose cached data, then react to load actions in the ViewModel. Initial load and retry can show a full loading state when there is no data yet. Refresh should usually keep the current list visible and only change the refresh flag. If the network call fails during refresh, keep the last good data and mark it stale.
Share the result as StateFlow in the ViewModel with stateIn. That gives the screen one source of truth. New collectors get the latest state right away, and you do not rebuild loading logic in every fragment or composable.
Test the transitions one by one. The fastest way is to fake the repository and drive the trigger flow yourself. Check cases like:
- initial load -> content
- initial load -> error
- content -> refresh -> updated content
- content -> refresh fails -> stale content
- error -> retry -> content
If one of those paths needs two or three observers to make sense, the model is still too split. Push the logic back into the flow until each path reads like a simple state change.
Show stale data without confusing people
People get less annoyed by old data than by a blank screen that flickers in and out. If you already have good content, keep it on screen during refresh and tell the user what is happening.
That usually means your state needs one plain flag for staleness. In a Kotlin Flow screen state model, stale data is not a different screen. It is the same content, plus a note that says, in effect, "this may be out of date".
data class ScreenState(
val items: List<Item> = emptyList(),
val isRefreshing: Boolean = false,
val isStale: Boolean = false,
val canRetry: Boolean = false,
val errorMessage: String? = null
)
This small split helps a lot. isRefreshing tells the UI to show progress without hiding content. isStale tells the UI to show a small warning or badge. canRetry appears only after a failed request. That keeps retry tied to a real problem instead of showing it all the time.
A common case looks like this: a list loads at 9:00, the user comes back at 9:10, and the app refreshes in the background. Keep the 9:00 list visible. Show a light loading hint. If the request fails, keep the old list, set isStale = true, and show a retry action with a short message like "Couldn't update".
What you should not do is flip from content to full error state just because refresh failed. The user still has something useful on screen. Treat that as content with a refresh problem, not as an empty failure.
After a good fetch, clear the stale marker right away. Reset isStale, hide retry, and remove the error message in the same state update. That gives bug reports one place to point: the screen either had fresh content, stale content, or no content yet. No mystery observers, no mixed signals, and no guessing which callback changed the UI last.
Example: a list screen with refresh and retry
A list screen shows why one state model beats a pile of observers. Picture a user opening an app with an empty cache. There are no saved items yet, so the screen has only one honest state: loading with no content.
data class ListScreenState(
val items: List<Item> = emptyList(),
val initialLoading: Boolean = false,
val refreshing: Boolean = false,
val error: UiError? = null,
val stale: Boolean = false
)
On first open, initialLoading = true and items is empty. If that request fails, switch to initialLoading = false, keep items = emptyList(), and set error = LoadFailed. The UI can show a full error view because the user has nothing else to look at.
When the user taps Retry, you do not invent a second observer or a special retry screen. You update the same ListScreenState. Set initialLoading = true again, then fill items when the request succeeds. Now the screen shows the list and error = null.
Later, the app refreshes in the background or the user pulls to refresh. This time the screen already has data, so a failed request should not throw the user back to a blank error page. Keep the old items on screen, set refreshing = false, store the refresh error, and mark stale = true.
That gives you a state people can understand: the list is still usable, but it may be out of date. A small message like "Couldn't refresh. Showing saved results." fits this case much better than a full-screen failure.
This is where bug reports get cleaner. Instead of "spinner stayed on, toast showed, and the list looked old," the report can point to one Android screen state: items present, error present, stale true. In Kotlin Flow screen state, that is the whole point. One model tells you what the user saw and why.
Mistakes that bring observer tangles back
Observer tangles usually return through small shortcuts. The code still uses Flow, but the screen no longer has one clear story.
A common mistake is mixing booleans that can fight each other. If you keep isLoading, hasError, and isEmpty as separate flags, the screen can land in impossible combinations. A spinner and an error message can appear at the same time. The retry button might show while data is still loading. A single state model avoids that because each case has one meaning.
Another problem starts when two collectors touch the same widget. One collector updates the list. Another collector shows and hides a spinner. A third one handles errors. It works for a week, then someone adds refresh and the UI starts to flicker. One part of the screen says "loaded" while another still says "loading." In a clean Kotlin Flow screen state setup, one renderer reads one state object and updates the whole screen area.
State also gets messy when it depends on view callbacks. If the ViewModel needs the Fragment to report things like "the empty view was shown" or "the adapter has no items," the source of truth is gone. Rotate the device or restore the process, and you cannot rebuild the same state with confidence.
null is another quiet source of bugs. A null list might mean "not loaded yet," "load failed," "user cleared filters," or "refresh in progress." That is too much work for one value. Name the case instead.
First load and refresh should not share the same spinner. On first load, a full-screen loader often makes sense because there is no content yet. During refresh, hiding old content behind the same spinner usually feels broken. Keep the stale data visible, show that an update is running, and let people retry without losing context.
A quick gut check helps:
- Can two flags disagree with each other?
- Can more than one collector change the same view?
- Does
nullcarry more than one meaning? - Can the app rebuild screen state without asking the view what happened?
- Does refresh hide usable data for no good reason?
If the answer is yes, the observers are starting to multiply again.
Quick checks before you ship
A screen state model is ready when people outside the feature team can read it and use it. If QA files a bug, they should point to one named state, not a vague mix of flags like "loading plus maybe error plus cached data".
A simple test helps fast: take any screenshot of the screen and ask, "Which state is this?" If two developers give different answers, the model is still fuzzy. In a clean Kotlin Flow screen state setup, one visible screen maps to one state class or one clear branch of the model.
Before release, check these cases with real examples:
- Open the screen on a slow network. Does the loading view have one clear meaning, or can it collide with an empty state?
- Trigger an error after data already loaded. Can the app retry and keep the old content on screen, or does it throw away good data for no reason?
- Age the cached data on purpose. Can people still use the screen while you show that the content may be stale?
- Hand the state names to QA. Can they write "stale content with refresh in progress" instead of describing pixels and hoping a developer guesses right?
- Ask a new teammate to trace the transitions. Can they follow the path from initial load to content to refresh to failure in a few minutes?
Tests should mirror the states people can see. Write one test for fresh loading, one for stale data with background refresh, one for empty success, and one for retry after failure. If you need many mocks just to reach a state, the model probably has too many moving parts.
One small rule saves a lot of pain: retry should change only what retry needs to change. If users already have a good list, keep it there and expose a retrying or refreshing state around it. That keeps bug reports precise. "Retry from stale data failed" tells you far more than "screen broke after tap."
When this passes, your state model becomes a shared language. Developers debug faster, QA reports better issues, and new teammates spend less time untangling observers.
Next steps for your team
Pick one screen and audit it with a pencil, not a rewrite plan. Open the UI, trigger a slow load, force an error, pull to refresh, go offline, then come back online. Write down every state a user can actually see. If two people on the team describe the same moment in different words, your model is still fuzzy.
A simple pass usually finds the same mess: isLoading, isRefreshing, errorMessage, cached items, and one-off retry flags spread across the ViewModel and UI. Replace that pile with one screen model. The point is not prettier code. The point is that bug reports can map to one state instead of five booleans that drift apart.
A small checklist helps:
- List visible states for one screen only
- Merge scattered flags into one sealed model or one clear state object
- Keep transient effects, like toasts or navigation, outside that state
- Write tests for retry, stale data, and recovery before broad cleanup
Start the tests with the paths teams skip. Retry logic often works on the happy path and breaks after a second failure. Stale data is another common blind spot. A screen that shows old content with a small "last updated" hint is often better than a blank loader, but only if the state model says that clearly. If you use Kotlin Flow screen state well, these cases stop feeling special. They become normal branches in one flow.
Keep the first refactor narrow. One list screen is enough to prove the pattern. If developers need three meetings to explain the state tree, it is too complicated.
If this change starts touching repositories, caching rules, navigation, and app-wide conventions, get a short architecture review before you copy the pattern everywhere. An experienced Fractional CTO such as Oleg can spot where a local cleanup turns into a wider app decision. That kind of check is boring, quick, and often cheaper than undoing a half-finished state model across the codebase.