Skip to content
Sudip KC writing notes in a journal at a desk
SK.
← All articles

UI/UX · · 6 min read

Handling Loading, Error and Empty States Properly

  • UI/UX
  • React
  • Accessibility
  • Design Systems
  • Tutorial

Designs usually show the happy path: a full table, a perfect profile, a dashboard with clean numbers. Users spend a surprising amount of time somewhere else, staring at a spinner, an error, or a blank screen that explains nothing.

I learned this the uncomfortable way. Early in my career I'd hand off Figma screens with every pixel considered, and then the developer would ask, "What does this look like before the data arrives?" I didn't have an answer. Now I design those states first, because they're where trust gets won or lost.

Every data view has at least four states

For any screen that loads data, I list the states before I draw anything:

  1. Loading: we've asked and are waiting.
  2. Error: the request failed, or we're offline.
  3. Empty: the request worked, but there's nothing to show.
  4. Success: the happy path everyone designs.

There are a couple more worth naming in complex apps:

  • Partial: some data loaded, one widget failed.
  • Refreshing: we have old data and are fetching new data in the background.
  • No permission: the data exists, but this user can't see it.

If the design doesn't cover a state, the developer will invent one under deadline pressure. That's how you end up with a raw Error: Request failed with status code 500 on a client's screen.

Loading: show the shape, not a spinner

A centred spinner tells the user something is happening. A skeleton tells them what is about to happen. I use skeletons for anything with a predictable layout: cards, tables, profile headers. They reduce layout shift and make the wait feel shorter because the eye has somewhere to go.

A few rules I follow:

  • Match the real layout. A skeleton that looks nothing like the final content causes a jarring jump.
  • Delay it slightly. If data arrives within about 200ms, showing a skeleton just creates a flicker. A short delay avoids that.
  • Don't block the whole page. Load each section independently so the fast parts appear first.
  • Keep buttons honest. A submit button that's busy should say so and be disabled, so nobody double-submits an order.

In Next.js, loading.tsx and Suspense boundaries make the section-by-section approach natural. In client components, I lean on TanStack Query's status flags.

Error: say what happened and what to do next

A good error message answers three questions: what went wrong, is it my fault, and what can I do now? "Something went wrong" answers none of them.

I separate errors by type because the right response differs:

  • Offline: "You're offline. We'll show your saved data and retry when you're back."
  • Server error: "We couldn't load your orders. Try again in a moment."
  • Not found: "This reservation doesn't exist or was deleted."
  • Forbidden: "You don't have access to inventory reports. Ask a manager."
  • Validation: shown next to the field, never in a generic toast.

And almost every error needs a retry action. Not a reload-the-whole-page instruction, but a button that re-runs that one request.

An error state without a next step is just the app shrugging at the user.

Empty: the most wasted screen in software

Empty states are where I see the most missed opportunity. A blank table with "No data" is technically correct and completely useless.

There are really three kinds of empty, and they need different copy:

  • First use: the user hasn't created anything yet. Explain what this area is for and give one clear action: "No menu items yet. Add your first dish."
  • No results: a search or filter returned nothing. Show what they searched and offer to clear filters.
  • Cleared: they finished everything, like an empty kitchen queue. That's good news. Say so.

Working on the Membersathi screens at GETS drove this home for me. For a brand-new account, an empty state is often the very first screen. Treating it as onboarding, not as an absence of content, makes those first minutes feel guided instead of broken.

A pattern I reuse in React

Rather than rewrite the same if (isLoading) chain in every component, I keep a small component that takes a query result and renders the right state. Here's a trimmed version of the shape I use with TanStack Query:

import type { UseQueryResult } from "@tanstack/react-query";
import type { ReactNode } from "react";

type QueryStateProps<T> = {
  query: UseQueryResult<T>;
  skeleton: ReactNode;
  empty: ReactNode;
  isEmpty?: (data: T) => boolean;
  children: (data: T) => ReactNode;
};

export function QueryState<T>({
  query,
  skeleton,
  empty,
  isEmpty = (data) => Array.isArray(data) && data.length === 0,
  children,
}: QueryStateProps<T>) {
  if (query.isPending) return <>{skeleton}</>;

  if (query.isError && !query.data) {
    return (
      <div role="alert" className="rounded-xl border p-6 text-sm">
        <p className="font-medium">{describeError(query.error)}</p>
        <button
          type="button"
          onClick={() => query.refetch()}
          disabled={query.isFetching}
          className="mt-3 underline"
        >
          {query.isFetching ? "Retrying…" : "Try again"}
        </button>
      </div>
    );
  }

  const data = query.data as T;
  if (isEmpty(data)) return <>{empty}</>;

  return <>{children(data)}</>;
}

Two details matter here. First, the error branch only takes over when there's no data. If we already have cached data and a background refetch fails, I keep showing the data with a small "couldn't refresh" notice instead of wiping the screen. Second, the retry button shows its own busy state, so a user tapping it on a slow connection knows the tap registered.

Usage stays readable:

<QueryState
  query={ordersQuery}
  skeleton={<OrderListSkeleton rows={6} />}
  empty={<EmptyOrders onCreate={openNewOrder} />}
>
  {(orders) => <OrderList orders={orders} />}
</QueryState>

Accessibility is part of the state

States aren't only visual. A screen reader user needs to know that content is loading or that an error appeared.

  • Use role="alert" or an aria-live region for errors that appear after an action.
  • Mark loading regions with aria-busy="true" while content is pending.
  • Move focus sensibly after errors in forms, usually to the first invalid field.
  • Don't rely on colour alone. A red border needs a text message beside it.

The MDN guide on ARIA live regions is worth reading once if you haven't.

Designing it in Figma first

I now include a states row for every data component in the design file. Each card, table or list gets loading, empty, error and success variants side by side. It takes maybe an extra hour per feature and saves days of back-and-forth. I described the rest of that handoff in From Figma to Production: My UI/UX → React Workflow.

On complex dashboards, where one screen might have eight independent widgets, I design how each widget fails on its own. A broken revenue chart shouldn't take down the whole page. More on that in How I Design Dashboards for Complex Applications.

My checklist before shipping a screen

  • What does it look like on first visit with no data?
  • What does it look like on a slow 3G connection for five seconds?
  • What happens if the API returns a 500? A 403? A 404?
  • What if I'm offline when I open it?
  • Can I retry without reloading?
  • Does a screen reader announce the error?
  • Does old data stay visible while refreshing?

The short version

The happy path is the state your users see when everything goes right. The other states are what they see the rest of the time, and on networks like many of ours in Nepal, that's often. Design them on purpose, write copy that helps, give every failure a way forward, and build one reusable pattern so your team doesn't reinvent it per screen.