State Handling for Loading, Empty, and Error Screens

Capítulo 13

Estimated reading time: 13 minutes

+ Exercise

Why Loading, Empty, and Error States Matter

Most screens in a mobile app are not “ready” the moment they appear. Data may still be fetching, a user may have no content yet, or something may fail. State handling is the practice of explicitly modeling these situations and rendering the appropriate UI for each one. Instead of treating loading, empty, and error screens as afterthoughts, you design them as first-class states with clear transitions between them.

Good state handling improves perceived performance, reduces confusion, and prevents UI glitches like flickering content, stale data, or buttons that do nothing. It also makes your code easier to reason about: rather than scattering conditional checks across the UI, you define a small set of states and map each state to a predictable view.

Define the Core Screen States

For most data-driven screens, you can start with a simple state model:

  • Loading: the app is waiting for data (initial load or refresh).
  • Success (Content): data is available and can be displayed.
  • Empty: the request succeeded but returned no items (or the user has not created any yet).
  • Error: the request failed (network, server, parsing, permission).

In practice, you often need a few refinements:

  • Loading with cached content: show existing content while fetching updated data (common for pull-to-refresh).
  • Partial content with error: show what you have, but indicate that some parts failed (e.g., “Some items couldn’t be loaded”).
  • Paginated loading: loading more items at the bottom while the list is already visible.
  • Optimistic updates: show a change immediately while the server request is pending, and revert if it fails.

The key is to keep the state space small and explicit. If you find yourself with many boolean flags (isLoading, hasError, isEmpty, isRefreshing, isPaginating), you risk invalid combinations (e.g., isLoading=true and hasError=true) unless you carefully coordinate them. A single “state object” or “sealed union” style model prevents these contradictions.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

Download the app

Model State Explicitly (Avoid Boolean Soup)

A robust approach is to represent screen state as one of a few mutually exclusive variants. The exact syntax depends on your platform, but the idea is universal.

Example: State model as a discriminated union

// Pseudocode (platform-agnostic) for a list screen state model
ScreenState =
  | Loading
  | Content(items)
  | Empty
  | Error(message, canRetry)
  | ContentWithRefreshing(items)
  | ContentWithPagination(items, isLoadingMore, loadMoreError?)

This model makes it hard to accidentally show an empty view and a list at the same time. It also forces you to decide what the UI should do in each case.

Mapping state to UI

render(state):
  switch state:
    case Loading: showSkeleton()
    case Empty: showEmptyState()
    case Error: showErrorState()
    case Content(items): showList(items)
    case ContentWithRefreshing(items): showList(items, refreshing=true)
    case ContentWithPagination(...): showListWithFooter(...)

When you implement this mapping, aim for a single place where the decision is made (a “state renderer”), rather than sprinkling conditions across multiple components.

Loading State: What to Show and When

Loading UI should answer two questions for the user: “Is the app working?” and “What will appear here?” There are three common loading patterns, each suited to different contexts.

1) Skeleton screens (preferred for content lists)

Skeletons are placeholder shapes that resemble the final layout (rows, avatars, cards). They reduce perceived waiting time because the structure is visible immediately. Use skeletons when you know what the layout will be and the content is likely to arrive soon.

  • Match the skeleton to the final layout (same spacing and approximate sizes).
  • Keep animation subtle; avoid distracting shimmer intensity.
  • Limit the number of skeleton items to what fits on screen (don’t render 50 placeholders).

2) Inline spinners (good for small areas)

Use an inline spinner when only part of the screen is loading (e.g., a “Load more” footer, a button action, or a small panel). Inline indicators are less disruptive than full-screen loaders.

  • Disable the triggering control while loading to prevent duplicate requests.
  • Keep the spinner near the affected content (e.g., inside the button).

3) Full-screen loading (use sparingly)

A full-screen loader is appropriate when the screen cannot function without the data (e.g., first-time app setup, critical authentication checks). Overuse can make the app feel slow and block user exploration.

  • If possible, show a minimal shell of the screen (title, tabs, placeholders) instead of a blank overlay.
  • Provide a timeout strategy: if loading takes too long, transition to an error with retry.

Step-by-step: Implement initial loading vs refresh loading

Many screens need two different loading experiences: initial load (no content yet) and refresh (content exists but is being updated). Treat them as separate states.

  • Step 1: On screen entry, set state to Loading and start the fetch.
  • Step 2: If fetch succeeds with items, set state to Content(items).
  • Step 3: If fetch succeeds with zero items, set state to Empty.
  • Step 4: If fetch fails, set state to Error(message, canRetry=true).
  • Step 5: When user pulls to refresh, set state to ContentWithRefreshing(existingItems) (not Loading), start fetch, then replace items on success or show a non-blocking error on failure.

This prevents a common mistake: replacing a populated list with a full-screen loader during refresh, which feels like the content disappeared.

Empty State: Differentiate “Nothing Yet” from “Nothing Matches”

An empty state occurs when the app successfully loaded data, but there is nothing to show. Empty states should be informative and action-oriented, but they must also be honest: do not imply an error if the request succeeded.

Common types of empty states

  • First-use empty: the user hasn’t created anything yet (e.g., no saved items, no messages).
  • Filtered empty: the user applied filters/search and no results match.
  • Permission-based empty: content exists but cannot be shown until permission is granted (e.g., contacts, photos). This is not a network error; it’s a blocked state.
  • Feature-gated empty: content requires enabling a setting or completing a step (e.g., connect account).

What an effective empty state contains

  • Plain-language explanation of why it’s empty (one sentence).
  • Primary action that helps the user move forward (e.g., “Create item”, “Clear filters”, “Invite someone”).
  • Secondary action when relevant (e.g., “Learn more”, “Browse templates”).
  • Optional illustration that supports the message but does not replace it.

Be careful with empty states in lists that can be filtered. If the user has active filters, the empty state should focus on the filters (“No results for these filters”) rather than implying the user has no data at all.

Step-by-step: Empty state for a filtered list

  • Step 1: After a successful fetch, check if items.length == 0.
  • Step 2: If there are active filters/search terms, render a “No matches” empty state with a Clear filters action.
  • Step 3: If there are no filters, render a “Nothing yet” empty state with a Create or Add action.
  • Step 4: Keep the filter/search UI visible so the user can adjust without navigating away.

Error State: Make Failures Recoverable

Error states should do three things: explain what happened in user-friendly terms, preserve user progress when possible, and provide a clear recovery path. The recovery path is usually retry, but sometimes it’s “Check connection”, “Sign in again”, “Update app”, or “Contact support”.

Classify errors by what the user can do

  • Transient errors (timeouts, flaky network): show retry; consider auto-retry with backoff.
  • Authentication errors (expired session): prompt to sign in again; avoid infinite retry loops.
  • Permission errors: guide to grant permission; provide a button to open system settings if appropriate.
  • Validation errors (form submission): highlight fields and show inline messages rather than a full-screen error.
  • Server errors (5xx): retry and optionally show status page link if your product supports it.

Error message guidelines

  • Use a short title (“Couldn’t load items”).
  • Provide a brief cause when known (“Check your connection and try again”).
  • Avoid exposing raw codes (“Error 503”) unless it helps support; if you include it, put it in a secondary area.
  • Offer a primary action (“Retry”).

Also consider whether you should show a full-screen error or an inline error. If the screen has no usable content without the data, full-screen is fine. If you have cached content, prefer an inline banner/toast so the user can keep using what’s available.

Step-by-step: Retry with backoff and cancellation

Retries can improve reliability, but uncontrolled retries can waste battery and frustrate users. A practical approach:

  • Step 1: On failure, transition to Error and show a Retry button.
  • Step 2: If you implement auto-retry, limit attempts (e.g., 2–3) and use exponential backoff (e.g., 1s, 2s, 4s).
  • Step 3: Cancel in-flight requests when the user navigates away to prevent late responses from updating a disposed screen.
  • Step 4: If retry succeeds, transition to Content or Empty based on results.
// Pseudocode for safe retry
attempt = 0
maxAttempts = 3

function load():
  setState(Loading)
  requestId = newRequestId()
  currentRequestId = requestId

  fetchData()
    .then(data => {
      if (currentRequestId != requestId) return // ignore stale response
      if (data.items.length == 0) setState(Empty)
      else setState(Content(data.items))
    })
    .catch(err => {
      if (currentRequestId != requestId) return
      setState(Error(userMessage(err), canRetry=true))
    })

function retry():
  attempt += 1
  if (attempt > maxAttempts) return
  delay = 2^(attempt-1) seconds
  wait(delay)
  load()

Prevent UI Flicker and State Thrashing

A common problem is rapid switching between states, especially on fast networks or when multiple requests overlap. This can cause flicker: skeleton appears for a split second, then content, then loading again.

Techniques to reduce flicker

  • Minimum loading display time: if loading starts, keep the loading UI visible for at least a short threshold (e.g., 300–500ms) so it doesn’t flash.
  • Debounce refresh triggers: prevent multiple refresh actions from firing back-to-back.
  • Single source of truth: ensure only one component owns the state transitions; avoid multiple layers setting state independently.
  • Ignore stale responses: tag requests and discard late responses from older requests (see requestId pattern above).

Use minimum display time carefully: it should reduce flicker, not artificially slow down the app. Apply it mainly to skeletons or full-screen loaders, not to inline spinners where immediate feedback is important.

Pagination: Loading and Error at the Bottom of a List

Pagination introduces additional states beyond the initial load. You typically want the list to remain visible while more items load, and you need a strategy for “load more” failures.

Recommended pagination states

  • Idle: list is visible, not currently loading more.
  • Loading more: show a footer spinner.
  • Load more error: show a footer error with a retry action (do not replace the whole list).
  • No more results: optionally show a subtle “End of results” indicator, or nothing.

Step-by-step: Footer-based pagination

  • Step 1: Render the list with a footer area reserved for pagination UI.
  • Step 2: When the user scrolls near the end, trigger loadMore() if not already loading and if more pages exist.
  • Step 3: Set pagination substate to LoadingMore and show a footer spinner.
  • Step 4: On success, append items and set pagination substate back to Idle.
  • Step 5: On failure, set pagination substate to LoadMoreError and show a footer retry button.
// Pseudocode: content state with pagination
state = ContentWithPagination(items, isLoadingMore=false, loadMoreError=null)

function loadMore():
  if (state.isLoadingMore) return
  setState(state.copy(isLoadingMore=true, loadMoreError=null))
  fetchNextPage()
    .then(newItems => setState(state.copy(items=items+newItems, isLoadingMore=false)))
    .catch(err => setState(state.copy(isLoadingMore=false, loadMoreError=userMessage(err))))

Offline and Cached Data: When “Error” Should Still Show Content

If your app caches data, an error does not always mean the screen should be empty. A better experience is often: show cached content, and display a non-blocking indicator that the latest update failed.

Practical patterns

  • Stale-while-revalidate: show cached content immediately, fetch updates in the background, then refresh the UI when new data arrives.
  • Offline banner: when connectivity is lost, show a small banner (“You’re offline”) while keeping content visible.
  • Last updated timestamp: optionally show “Updated 2 hours ago” in a subtle place for data where freshness matters.

In these cases, your state model might include Content(items, isStale=true, lastUpdated=...) or a separate “network status” signal that affects banners and refresh behavior without replacing the main content.

Forms and Mutations: Loading and Error Without Leaving the Screen

Not all state handling is about fetching lists. Submitting a form, saving settings, or performing an action (delete, favorite, checkout) also needs clear loading and error handling. Here the UI should remain stable and focus feedback near the action.

Guidelines for action states

  • Button-level loading: show a spinner inside the button and disable it while the request is in flight.
  • Preserve input: if submission fails, keep the form fields as the user entered them.
  • Inline validation: for field errors, show messages near the fields rather than a generic alert.
  • Undo for destructive actions: if you remove an item optimistically, offer undo while the server confirms.

Step-by-step: Save action with optimistic UI and rollback

  • Step 1: When user taps Save, disable the Save button and show button spinner.
  • Step 2: Optionally update the local UI immediately (optimistic) to reflect the new value.
  • Step 3: Send the request.
  • Step 4: On success, keep the new value and re-enable the button.
  • Step 5: On failure, restore the previous value (rollback) if you updated optimistically, show an inline error message, and re-enable the button.

Accessibility and Clarity in State UI

State screens must be understandable to all users, including those using assistive technologies.

  • Announce state changes: when loading completes or an error appears, ensure the change is announced (e.g., via accessibility live regions or platform equivalents).
  • Don’t rely on color alone: error states should include icons/text, not just red highlights.
  • Provide focus targets: when an error screen appears, focus should move to the error title or retry button so keyboard/switch users can act.
  • Readable messages: keep text concise and avoid jargon; if you must include details, put them behind an expandable “Details” section.

Testing State Handling: What to Verify

Because state handling is about edge cases, you should test each state intentionally.

Checklist for a data-driven screen

  • Initial load: loading UI appears, then transitions to content/empty/error correctly.
  • Empty response: empty state appears with correct actions; filters/search still usable.
  • Error response: error state appears with retry; retry works; repeated retries don’t stack requests.
  • Refresh: existing content remains visible; refresh indicator shows; failure shows non-blocking message.
  • Pagination: footer loading appears; load-more error doesn’t wipe the list; retry loads next page.
  • Navigation away: in-flight requests are canceled or ignored; no late UI updates cause crashes.
  • Offline: cached content shows if available; offline messaging is clear.

Practical approach to simulate states

During development, add a debug menu or developer toggle that forces each state (Loading, Empty, Error, Content) without relying on real network conditions. This makes it easy to verify layout, copy, and actions for every state before release.

Now answer the exercise about the content:

When a user pulls to refresh a list that already has items, which UI state handling approach best prevents the content from disappearing?

You are right! Congratulations, now go to the next page

You missed! Try again.

For refresh, the UI should keep existing content visible and use a state like ContentWithRefreshing. This avoids replacing a populated list with a full-screen loader, which feels like the content disappeared.

Next chapter

Forms and Input Layout for Mobile Constraints

Arrow Right Icon
Free Ebook cover Mobile App UI Fundamentals: Layout, Navigation, and Responsiveness
81%

Mobile App UI Fundamentals: Layout, Navigation, and Responsiveness

New course

16 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.