Skip to content
All posts
May 4, 2026·6 min read

Your App Should Know How to Talk

Most apps nail the happy path but go silent when things get slow, empty, or broken. Here's how to handle loading, error, empty, and success states — and why good UI is a conversation, not a monologue.

#frontend#ux#react#mobile

"Waiting isn't the problem. Uncertainty is."

(i) When Silence Gets Expensive: Real Evidence from the Field

Imagine ordering food through an app — you tap "Order Now" and nothing happens. No spinner, no confirmation, no feedback. You tap again. And again. You've just placed three orders.

This isn't hypothetical. It's the kind of failure that plays out across products every day — and in some cases, at massive scale.

Case 1: Facebook Down, 3 Billion Users Left to Guess

On October 4, 2021, Facebook, Instagram, and WhatsApp went down for nearly 6 hours. Users faced endless loading screens and generic errors with no explanation, no estimate, and no guidance.

The result: Downdetector received over 10.6 million reports. Meta's stock dropped nearly 5%. The company lost approximately $60 million in advertising revenue that day.

The fundamental problem wasn't the outage. It was the silence.

Case 2: Ticketmaster and the Taylor Swift Concert

November 2022. Ticketmaster's website crashed during Taylor Swift's Eras Tour presale. Around 3.5 million verified fans encountered:

  • Queue systems with no position feedback
  • Two-hour waits with zero clarity on status
  • No confirmation of whether a transaction succeeded or failed
  • Users forced to check Twitter for updates

Consequences: class action lawsuits, U.S. senators summoning Ticketmaster executives to Capitol Hill. The core issue wasn't just server capacity — it was communication failure.

Case 3: Gojek Error, Balance Deducted but Order Never Arrived

September 2023. A Gojek outage deducted users' balances but never relayed orders to drivers. Users couldn't tell: should I retry (risking a double charge)? Wait? Call support immediately?

The Common Thread

All three failures had one thing in common: they did not communicate in non-success conditions.

"88% of users will not return to an app or website after a bad experience, and 53% of mobile users will abandon an app if loading takes more than three seconds with no feedback."


(ii) The Root of the Problem: We're Too Focused on the Happy Path

Frontend developers naturally design around the ideal flow — successful data loads, smooth submissions, stable connections. But real-world conditions don't cooperate.

"A good app isn't just one that displays data beautifully. It's one that knows how to communicate in every condition — especially the ones that aren't ideal."

Blank screens create stress. First-time users don't know whether to wait, refresh, or navigate elsewhere. Silence is the worst UX you can ship.


(iii) Understanding UI State: Four Conditions You Must Handle

1. Loading State

Appears during data fetching, request processing, or server response waits.

When ignored: Users see blank screens and tap multiple times (double-submit), or abandon the page assuming failure.

Upgrade move: Skeleton screens beat simple spinners. By mimicking the layout with grey placeholders, they give users context about the incoming content and eliminate jarring transitions.

2. Error State

Often the most poorly implemented state. Generic messages or blank screens erode trust.

Effective error states have three parts:

  1. Explain what happened in plain language (not error codes)
  2. Explain why — "Your internet connection is lost", not "Error 503"
  3. Provide an exit path — a "Try Again" button, an alternative link, a clear next step

3. Empty State

Occurs when a component has no data — new users, empty search results, no history yet.

Effective empty states include: a relevant illustration or icon, a clear title (not just "Empty"), a contextual description, and — the most commonly forgotten part — a Call to Action.

4. Success State

Covers successful data loads and processed user actions.

Users need confirmation for form submissions, file uploads, and messages. Without it, they click again. A toast notification, confirmation page, or button state change closes the loop.


(iv) Let's Talk Implementation

The Most Common Codebase Problems

Pattern 1: Unreadable nested ternaries

function UserList({ userId }) {
  const { data, isLoading, error } = useUsers(userId);
  return isLoading ? (
    <p>Loading...</p>
  ) : error ? (
    <p>Error!</p>
  ) : data.length === 0 ? (
    <p>Empty</p>
  ) : (
    <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>
  );
}

Difficult to read, scattered logic, hard to extend.

Pattern 2: State that isn't handled at all

function UserList({ userId }) {
  const { data } = useUsers(userId);
  // If data is undefined during loading, this crashes
  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

The Solution: Build Reusable State Components

Create a single wrapper component that handles all state conditions centrally:

function AsyncWrapper({
  isLoading,
  error,
  isEmpty,
  onRetry,
  loadingFallback,
  emptyFallback,
  children
}) {
  if (isLoading) return loadingFallback ?? <DefaultSkeleton />;
  if (error) {
    return (
      <div className="error-state">
        <p>{error.message ?? "Something went wrong. Please try again."}</p>
        {onRetry && <button onClick={onRetry}>Try Again</button>}
      </div>
    );
  }
  if (isEmpty) return emptyFallback ?? <DefaultEmptyState />;
  return children;
}

Usage:

function TransactionPage() {
  const { data, isLoading, error, refetch } = useTransactions();
  return (
    <AsyncWrapper
      isLoading={isLoading}
      error={error}
      isEmpty={data?.length === 0}
      onRetry={refetch}
      emptyFallback={
        <EmptyState
          title="No transactions yet"
          description="Your first transaction will appear here."
          action={<button onClick={() => navigate('/transfer')}>Make a Transaction</button>}
        />
      }
    >
      <TransactionList data={data} />
    </AsyncWrapper>
  );
}

Custom Hook for State Management

Encapsulate state logic with explicit states — 'idle', 'loading', 'success', 'error', 'empty':

type AsyncStateType = 'idle' | 'loading' | 'success' | 'error' | 'empty';

export function useAsyncState<T>() {
  const [data, setData] = useState<T | null>(null);
  const [state, setState] = useState<AsyncStateType>('idle');
  const [error, setError] = useState<string | null>(null);

  const execute = useCallback(async (fn: () => Promise<T>) => {
    setState('loading');
    try {
      const result = await fn();
      const isEmpty = Array.isArray(result) && result.length === 0;
      setState(isEmpty ? 'empty' : 'success');
      setData(result);
    } catch (err) {
      setState('error');
      setError(err instanceof Error ? err.message : 'Something went wrong');
    }
  }, []);

  return { data, state, error, execute };
}

Using a union type prevents loading and error from being true simultaneously — eliminating an entire class of bugs.

Buttons That Wait for a Response

function SubmitButton({ onSubmit }) {
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleClick = async () => {
    setIsSubmitting(true);
    try {
      await onSubmit();
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <button onClick={handleClick} disabled={isSubmitting}>
      {isSubmitting ? 'Saving...' : 'Save'}
    </button>
  );
}

Disabling during submission prevents double-submits and gives immediate feedback.

Checklist Before Merging a Pull Request

Loading State

  • Clear visual indicator while data loads?
  • Action buttons disabled during processes?
  • Skeleton screen or spinner in place?

Error State

  • Error shown in plain language (not HTTP codes)?
  • Explanation of why it occurred?
  • "Try Again" button or next-step guidance?
  • Different error types handled differently?

Empty State

  • Message explaining why data is empty?
  • CTA guiding the next action?
  • Alternative suggestions for empty search results?

Success State

  • Data loaded properly after fetch?
  • Visual confirmation after action succeeds?

Conclusion: Your App is a Conversation

Frontend development is building conversations between products and humans. Each state communicates something:

  • Loading: "Hold on, I'm getting that for you."
  • Error: "Something went wrong — here's what happened, and here's what you can do."
  • Empty: "Nothing here yet, but you can start right here."

An app that goes silent during these moments disrespects its users. You have complete control to change this — starting with your next pull request.