React|Server State and Async Data

Declarative Async Handling: Gracefully Managing Loading/Errors with Suspense and Error Boundary

3
Declarative Async Handling: Gracefully Managing Loading/Errors with Suspense and Error Boundary

Chris's component code is always scattered. He intends to display a 'User Profile', but when you open the file, more than half of the code is occupied by loading and error checks.

// ❌ Imperative Approach: Focusing on "How"
function UserProfile() {
  const { data, isLoading, isError } = useQuery({ ... });

  // 1. Loading Check
  if (isLoading) return <Spinner />;
  
  // 2. Error Check
  if (isError) return <ErrorMessage />;

  // 3. Null Data Check
  if (!data) return null;

  // 4. Finally, the main point (Happy Path)
  return <div>{data.name}</div>;
}

This code is called "Imperative." It commands what to draw step-by-step based on the state.

Wouldn't it be nice if we could strip away all this annoying check logic and write code assuming "Data definitely exists"?

Using React's Suspense and Error Boundary makes this dreamlike "Declarative" handling possible.

1. Suspense: "Wait Until Ready"

Suspense is a feature fully supported since React 18. When a component signals that it is "not ready to render yet (Suspend)," React finds the nearest parent Suspense and displays a Fallback (Loading Screen) instead.

Combining TanStack Query with Suspense

From v5 onwards, TanStack Query provides a dedicated hook called useSuspenseQuery. This hook 'suspends' the component execution if there is no data. It doesn't even return isLoading. Why? Because if the code passed this line, the data is guaranteed to exist.

// ✅ Declarative Approach: Focusing on "What"
import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile() {
  // No isLoading, No isError. 'data' is guaranteed not to be undefined.
  const { data } = useSuspenseQuery({ 
    queryKey: ['user'], 
    queryFn: fetchUser 
  });

  return <div>{data.name}</div>;
}

Now, the parent component takes responsibility for handling the loading state.

// ParentComponent.tsx
import { Suspense } from 'react';

function App() {
  return (
    // Show this spinner until the child is ready.
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  );
}

By doing this, you don't have to implement loading states in every single component. You can group them by section and manage them collectively.

2. Error Boundary: "Catch Errors from Above"

Just like loading, error handling works similarly. Just as JavaScript's try-catch propagates errors up the stack, errors occurring in React components bubble up like foam.

An Error Boundary captures these errors and displays an error screen instead. rather than implementing it yourself, it is recommended to use the battle-tested library react-error-boundary.

// App.tsx
import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <ErrorBoundary fallback={<div>An error occurred!</div>}>
      <Suspense fallback={<Spinner />}>
        <UserProfile />
      </Suspense>
    </ErrorBoundary>
  );
}

Trying Again (Reset)

If the error screen doesn't have a "Try Again" button, users will be frustrated. Using QueryErrorResetBoundary, you can reset the query and resend the request when an error occurs.

// App.tsx
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset} // Connect query reset function
          fallbackRender={({ resetErrorBoundary }) => (
            <div>
              Error occurred!
              {/* Clicking the button resets the query and triggers Suspense again */}
              <button onClick={resetErrorBoundary}>Try Again</button>
            </div>
          )}
        >
          <Suspense fallback={<Spinner />}>
            <UserProfile />
          </Suspense>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

3. Pros and Cons of Declarative Handling

Chris wants to change all his code to use Suspense. However, there are points to consider.

Pros

  • Code Readability: Only the business logic (Happy Path) remains inside the component, making it very clean.
  • UX Consistency: You can manage the position and appearance of loading spinners consistently from the parent.
  • Preventing Waterfalls: Grouping multiple async components under one Suspense can create the effect of parallel processing.
  • Cons and Cautions

  • User Experience: Sometimes, keeping the existing screen and showing a small loading indicator is better than the whole screen turning white and then popping in. (In this case, use useTransition).
  • Error Propagation: If an error bubbles up to the top level, the entire app might shut down. You must place Error Boundaries at appropriate levels.
  • Key Takeaways

  • Imperative vs. Declarative: if (loading) is imperative, while <Suspense> is declarative. Declarative is better for readability and maintenance.
  • useSuspenseQuery: A dedicated hook used in TanStack Query v5 to trigger Suspense. It guarantees the existence of data.
  • Error Boundary: Captures rendering errors occurring in the component tree and displays a replacement UI.
  • Combination: By combining Suspense and Error Boundary, you can gracefully separate the three states of async data (Success, Loading, Error).

  • We have mastered the most modern way to handle loading and errors. Now it's time to maximize User Experience (UX).

    When a user clicks the "Like" button, what if we fill the heart immediately without waiting for the server response?

    Continuing in: “useMutation and Optimistic Updates: Don't Let the User Feel Even 0.1s of Delay”

    🔗 References

  • TanStack Query - Suspense
  • React Docs - Suspense
  • react-error-boundary