Empty, loading, and error states decide whether users trust your app. Learn to design UI states first with Dart sealed classes, skeleton screens, and retry safe error handling.
Every screen in your app has at least four versions, but most teams design one and hope for the best. The happy path — the screen with real data, arranged the way the mockup promised — is the one everyone obsesses over. The other three (empty, loading, error) get bolted on in the last sprint before release, usually as a centered spinner and a red toast. And those three are exactly where users decide whether your product is trustworthy.
I've shipped enough production apps to know the pattern cold. A feature demos beautifully with seeded data. Then it hits a real user on a real network with an empty account, and the whole thing feels broken — not because the logic is wrong, but because nobody designed the moments around the data. This is the difference between UI that survives a demo and UI that survives production: the empty, loading, and error states are where perceived reliability is actually won or lost.
Think about when a user actually forms an opinion about your reliability. It's almost never when data loads correctly — that's table stakes, invisible. It's when something is missing, pending, or wrong. Those are the moments the user is paying attention, slightly anxious, deciding whether to trust you with the next tap.
Here's the reframing that changed how I build: these aren't states of a screen. They're states of the user's confidence. Design them last and you're patching confidence after you've already spent it. Design them first and you build confidence into the foundation, where a deadline can't strip it back out.
When you sketch the empty and error states before the happy path, something useful happens: you're forced to model your data as a proper state machine instead of a nullable blob. You stop writing if (data != null) and start enumerating what "no data" actually means.
There's a real distinction most UIs collapse: empty because nothing exists yet versus empty because a filter returned nothing versus empty because the request failed silently. Those are three different screens with three different copy blocks and three different actions. If you design the states first, you see those branches. If you design them last, they all become the same sad centered text.
In Dart I model this as a sealed hierarchy so the compiler forces me to handle every branch. Sealed classes (available in Dart 3 and later) are the right tool here precisely because the analyzer knows the complete set of subtypes at compile time:
sealed class ViewState<T> { const ViewState();}class Loading<T> extends ViewState<T> { const Loading();}class Empty<T> extends ViewState<T> { final String reason; // 'no_data' | 'no_results' | 'first_run' const Empty(this.reason);}class Failure<T> extends ViewState<T> { final Object error; final Future<void> Function() retry; const Failure(this.error, this.retry);}class Ready<T> extends ViewState<T> { final T data; const Ready(this.data);}The retry callback baked into Failure is the part I care about most. It makes it impossible to render an error without a way out — the type system won't let you build a dead end. That's a design decision enforced by architecture, which is the only kind that survives a deadline. Notice too that Empty carries a reason: the state machine itself refuses to let you forget why the screen is empty, which is the single most common collapse I see in real codebases.
The widget layer becomes a pure switch with no room to forget a case:
Widget build(BuildContext context) { return switch (state) { Loading() => const SkeletonList(), Empty(reason: final r) => EmptyView(reason: r), Failure(error: final e, retry: final onRetry) => ErrorView(error: e, onRetry: onRetry), Ready(data: final items) => ItemList(items), };}Because Dart's exhaustive switch on a sealed class won't compile if you drop a branch, "we forgot the error state" stops being a class of bug. That guarantee is worth more than any lint rule, because a lint can be ignored and a compile error cannot. If you're on Bloc, Riverpod, or plain ValueNotifier, this pattern drops in cleanly — the state object your notifier or bloc emits is the ViewState<T>, and the widget just switches on it.
This is also why I avoid the classic AsyncSnapshot juggling with hasData, hasError, and connectionState. Those booleans let contradictory combinations exist (data and error, loading with stale data), and every one of them is a branch you can forget. A sealed hierarchy makes the illegal states unrepresentable.
An empty state's job is to teach and to invite the first action. Never just describe the absence. The rule I follow: every empty state points at a next step. A notes app with no notes shows a "Create your first note" button, not "No notes found."
The critical distinction is between the first-run empty and the filtered empty, and collapsing them is the most common mistake I see:
reason: 'first_run') is welcoming and instructional. This is prime onboarding real estate — a short line explaining what the feature does, an illustration, and one obvious call to action. It should feel like an invitation, not a void.reason: 'no_results') means data exists but the current query or filter hid it. The right move is to acknowledge that ("No results for 'foobar'") and offer to clear the filter or broaden the search. Telling a searching user "Create your first note" here would be nonsense — they already have notes.That single reason field is why designing the state first pays off: you literally cannot render the wrong empty copy, because the branch is named in the type.
Skeleton screens beat spinners in almost every list-and-card layout because they preserve the layout's shape while the data arrives. The user's eye settles into the structure before the content lands, so the transition to real data is a fill, not a redraw. A spinner throws away all that spatial information and replaces it with "please wait."
Two things I've learned to guard against:
A minimal way to gate that flash in Flutter:
class DelayedLoader extends StatefulWidget { final Widget child; final Duration delay; const DelayedLoader({ super.key, required this.child, this.delay = const Duration(milliseconds: 200), }); @override State<DelayedLoader> createState() => _DelayedLoaderState();}class _DelayedLoaderState extends State<DelayedLoader> { bool _visible = false; Timer? _timer; @override void initState() { super.initState(); _timer = Timer(widget.delay, () { if (mounted) setState(() => _visible = true); }); } @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) => _visible ? widget.child : const SizedBox.shrink();}Wrap your SkeletonList in that and fast responses simply never flash. The other refinement worth making: when you do have stale-but-valid cached data, prefer showing it with a subtle refresh indicator over blanking to a skeleton. A stale list that quietly updates feels faster and calmer than a correct list that flickered through a loading frame to get there.
A good error screen answers three questions: what happened (in human words, not a stack trace), whether it's the user's fault or mine, and what to do next. The retry button is non-negotiable — and it should retry the specific failed operation, not reload the entire app.
The mistake here is treating every failure as one red screen. There are at least three distinct truths hiding behind "something went wrong":
Same red screen, three different truths. Because the Failure variant carries the actual error object, I can branch on its type in ErrorView and pick the right copy, tone, and action rather than shipping one generic apology for every possible failure. Two extra habits that pay off: log the real error and a correlation id for yourself while showing the human message to the user, and never leak a raw exception string into the UI — "SocketException: Failed host lookup" is a support ticket, "You're offline" is a reassurance.
The practical change is small and it compounds. When I pick up a new screen, the first artifact I produce isn't the happy path — it's a four-up: empty, loading, error, ready, side by side. Designing the three "unhappy" screens first surfaces the real questions early: What does a first-time user see? What's slow enough to need feedback? How can this fail, and what's the way out? Answer those before you write the fetch logic and the happy path almost designs itself.
I've also started treating these as a testing checklist. If I can't easily push a screen into its empty, loading, and error states from a widget test or a debug menu, that's a smell — it usually means the states are tangled into the fetch logic instead of being first-class values I can construct on demand. Modeling view state as a sealed class fixes that too: I can build a Failure(SocketException(), retry) in a test and assert the screen renders a retry button, no network mocking required.
switch turns "we forgot the error state" from a runtime bug into a compile error, and baking a retry callback into the failure variant makes dead-end error screens impossible.The payoff isn't just polish. It's that your product stops feeling like a demo that only works with the right data, and starts feeling like software that holds up when reality doesn't cooperate. That feeling is trust — and you build it in the three screens everyone else designs last.