You build the screen with the data already in it. Three rows in the table, a chart full of points, an avatar in the corner — because you seeded your dev database six months ago and it has never been empty since. You style that screen until it looks great, you ship it, and you move on. That screen looks finished to you because you only ever see it full.
The stranger who signs up tonight sees something else. They see the version with no rows, where your beautiful table is a thin grey line and a column header floating over nothing. They see the half-second where the data is still loading and the whole layout is a bare spinner on white. And if your API hiccups — which it will — they see a red box with the word undefined in it, or worse, a raw stack trace. The first impression of your product is almost never the state you designed. It's one of the three you didn't.
Open any feature you've shipped and count the states it can actually be in. A list of invoices isn't one screen. It's at least four. It's the screen a brand-new user sees with zero invoices. It's the screen mid-fetch while the request is in flight. It's the screen when the fetch fails. And it's the screen you actually built — the one with invoices in it.
Developers design the fourth and ship all four. The first three get whatever the framework gives them by default, which is to say nothing: a blank <div>, a spinner, or an unhandled rejection bubbling up as red text. Those defaults are where your product stops feeling like a product.
Every view that shows data has four states: empty, loading, error, and ideal — the last being the screen full of real data. Design all four, every time. Developers design only the fourth and let the framework's defaults stand in for the rest.
The reason this happens isn't laziness. It's that your environment hides three of the four states from you. Your dev database is never empty, your localhost is too fast to show a loading state for more than a frame, and your happy-path testing never trips the error. You can go months without seeing what a real user sees in their first ten seconds. The states aren't missing because you decided to skip them. They're missing because you literally never looked at them.
The first impression of your product is almost never the state you designed. It's one of the three you didn't.
This is the polish gap that costs you trust, and it ties straight back to the Competence Tax: a stranger can't see your clean architecture or your test coverage. They can only see the screen. When that screen is a blank box, they don't conclude "the data hasn't loaded yet." They conclude "this is broken," and brokenness is the cheapest possible signal that the rest is shoddy too.
The empty state is the screen the most important user in the world sees first: the one who just signed up and hasn't done anything yet. It's also, almost universally, the least designed screen in the app. That's backwards. The empty state is your onboarding. Treat it like a blank box and you've wasted your single best chance to point a new user at their first real outcome — the thing Time to First Win is entirely about.
There isn't one empty state, either. There are three, and they mean different things:
Collapsing all three into one generic "Nothing here" message is the tell of a demo. A new user reads "Nothing here" as a dead end. A user who just cleared a filter reads it as "the app lost my data." Same words, two different failures.
Here's the shape of a first-run empty state that does its job. It's not complicated — it's an icon, a sentence of context, and exactly one action that matches the screen's purpose.
function EmptyInvoices() {
return (
<div className="text-center py-16 px-6">
<FileIcon className="mx-auto h-10 w-10 text-slate-300" />
<h3 className="mt-4 text-base font-semibold text-slate-900">
No invoices yet
</h3>
<p className="mt-1 text-sm text-slate-500 max-w-sm mx-auto">
Create your first invoice and it will show up here.
Most people send theirs in under two minutes.
</p>
<button className="mt-6 rounded-md bg-indigo-600 px-4 py-2
text-sm font-medium text-white">
Create invoice
</button>
</div>
);
}
Notice the rules baked in. One primary action, matching One Primary Action — not three buttons competing for the click. The copy names the outcome ("Create invoice"), not the mechanism. And it sets a quiet expectation ("under two minutes") that lowers the activation energy. An empty state that just says "No data" makes the user feel lost. An empty state that says "here's the one thing to do next" makes them feel guided.
A search that returns nothing should never look identical to a brand-new account. If the user typed a query or set a filter, the empty state must acknowledge it and offer a one-click escape back to the full list. Showing a "create your first item" CTA after a failed search tells the user their data is gone.
The empty state is your onboarding screen in disguise — design it to point at the first real win, not to apologize for having nothing.
The default loading state is a spinner in the dead center of a white screen. It's the laziest thing in frontend development and it makes your app feel slower than it is. A centered spinner says one thing: "stop, wait, the system is busy." It erases the layout, gives the eye nothing to settle on, and turns every fetch into a blank pause.
A skeleton screen does the opposite. It shows the shape of the content before the content arrives — grey blocks where the text will be, a grey rectangle where the avatar goes, faint bars where the table rows will land. The user's eye starts parsing the layout immediately. The page feels like it's already there and just filling in, instead of frozen and then appearing all at once.
This is Perceived Over Actual in its purest form. You haven't made the request a millisecond faster. You've changed what the wait feels like, and the wait is what the user actually experiences. A 600ms fetch behind a skeleton feels quicker than a 400ms fetch behind a spinner, because the skeleton gives the brain something to do while it waits. A centered spinner says "stop and wait." A skeleton says "it's already here, just filling in."
A skeleton is cheap to build. It's the same layout with the content swapped for grey blocks and a subtle pulse.
function InvoiceRowSkeleton() {
return (
<div className="flex items-center gap-4 py-3 animate-pulse">
<div className="h-9 w-9 rounded-full bg-slate-200" />
<div className="flex-1 space-y-2">
<div className="h-3 w-1/3 rounded bg-slate-200" />
<div className="h-3 w-1/5 rounded bg-slate-100" />
</div>
<div className="h-3 w-16 rounded bg-slate-200" />
</div>
);
}
Render three to five of those where the rows will go and you have a loading state that mirrors the real layout. The trick is matching the dimensions: the skeleton row should be the same height as a real row so the page doesn't jump when data replaces it. A skeleton that's the wrong size produces a layout shift on load, which feels worse than the spinner you replaced.
Two guardrails keep skeletons honest. First, don't show a skeleton for an instant fetch — if the data usually arrives in under ~200ms, a skeleton that flashes for one frame is visual noise. Gate it behind a short delay so it only appears when the wait is real. Second, never block the entire page for one slow widget. Render the shell — nav, header, sidebar — immediately, and skeleton only the part that's actually loading.
Skeletons are for first loads of structured content you can predict the shape of — lists, cards, tables, profiles. A small inline spinner is still right for an action with no predictable layout, like the moment between clicking "Save" and the confirmation. Use the skeleton for content, the spinner for in-place actions.
When something fails, the user does not need to know that the promise rejected or that you got a 500 from the upstream service. They need to know two things, in plain words: what happened, and what they can do about it. A raw stack trace answers neither. It just confirms the worst suspicion — that the thing is broken and nobody's home.
The amateur error state is Error: undefined or a red box with a status code. The professional error state reads like a person wrote it. Compare these two for a failed load:
Bad: Error: Request failed with status code 500
Good: We couldn't load your invoices.
This is on us, not you — try again in a moment.
[ Retry ]
The good version does three things the bad one doesn't. It names what failed in the user's terms ("load your invoices," not "Request failed"). It assigns blame correctly — a server error is your fault, so say so, because nothing erodes trust like an app implying the user broke it. And it offers a next action: a retry button, so the user isn't stranded with a dead screen and no way forward.
The shape of a good error message is simple enough to make a habit: name the thing that failed, say whose fault it is, and give one action.
function LoadError({ onRetry }) {
return (
<div className="rounded-md border border-slate-200 bg-slate-50 p-6 text-center">
<p className="text-sm font-medium text-slate-900">
We couldn't load your invoices.
</p>
<p className="mt-1 text-sm text-slate-500">
Something went wrong on our end. Your data is safe.
</p>
<button onClick={onRetry}
className="mt-4 text-sm font-medium text-indigo-600">
Try again
</button>
</div>
);
}
Distinguish the kinds of failure, because they need different copy. A network failure ("Check your connection and try again") is the user's environment, so a retry makes sense. A validation failure ("That email is already in use") is something the user can fix, so point at the field. A server failure ("Something went wrong on our end") is yours to own, so apologize and offer retry without blaming them. Lumping all three under one generic "An error occurred" wastes the chance to tell the user whether they can do anything about it.
A React error boundary that renders the caught error's message, or an API that returns its exception text straight to the client, will eventually paint a stack trace onto a real user's screen. It looks broken and it leaks internals. Catch errors, log the technical detail to your monitoring, and show the human a sentence and a button.
There's a quieter category most apps miss entirely: the partial error. The list loaded but one row's avatar 404'd. The dashboard rendered but one widget's query timed out. Don't blow up the whole page for a partial failure — degrade locally. Show a broken-image placeholder for the one avatar, or a small "couldn't load this" note in the one widget, and let the rest of the screen stand.
The fix isn't talent. It's a habit, applied every single time you build a view that fetches data: before you write the happy path, write down the four states. Make it a comment block at the top of the component if that's what it takes.
// Invoices list — four states:
// empty: no invoices yet -> EmptyInvoices (CTA: create first)
// loading: -> 4x InvoiceRowSkeleton
// error: fetch failed -> LoadError (retry)
// ideal: render rows
Then your render logic has a branch for each, in a fixed order. Most "broken-looking" UIs are broken because one of these branches doesn't exist and the component falls through to rendering nothing, or to mapping over undefined.
if (error) return <LoadError onRetry={refetch} />;
if (isLoading) return <InvoiceListSkeleton />;
if (data.length === 0) return <EmptyInvoices />;
return <InvoiceList items={data} />;
Four lines. That ordering matters: check error first, then loading, then empty, then ideal — so a failed request never falls through into "you have no invoices," which would be a lie that reads as data loss.
Shipping a one-state view isn't a missing feature — it's an unfinished one. The other three states are where the product feels real.
Make it part of how you define "done." A feature isn't done when the happy path renders. It's done when all four states render and each one looks like it belongs to the same product. This is the same discipline as Happy Path First — you design the main flow first, yes, but "done" includes the states around it. The empty state guides, the loading state reassures, the error state recovers, and the ideal state delivers. Skip three of them and you've built a demo that happens to run in production.
A feature isn't done when the happy path renders. It's done when all four states render and look like the same product.
Open your app and write down each screen that fetches and displays data — lists, tables, dashboards, profiles, detail pages. That list is your audit. Each entry needs four states; most have one.
Pick the screen a new user sees first. Build a real empty state, a skeleton loading state, a human error state, and confirm the ideal state still looks right. Ship that one fully before touching the next.
Find a centered spinner on a list or card grid and swap it for a skeleton that mirrors the real layout. Match the row heights so the page doesn't jump when data arrives.
Hunt down a place where a status code, undefined, or a stack trace can reach the user. Replace it with a sentence that says what happened and a button that says what to do next.
From now on, no data view ships until all four branches exist and the render order is error, loading, empty, ideal. Make it a line in your PR checklist so it stops being optional.
Strangers always arrive at the state you didn't design. Design all four, and there's nowhere left for your product to look broken.