Handling Errors with Suspense

Avatar of Hemanta SundarayHemanta Sundaray

In the previous chapter, we created a Suspense-enabled UserGrid component. However, we didn’t handle errors. If the data fetch fails, useAtomSuspense throws the error and the app crashes.

To catch these errors and prevent crashes, React provides Error Boundaries. They wrap a section of your component tree and display a fallback UI when any child component throws.

Using React Error Boundary

First, install the react-error-boundary package:

Terminal
pnpm add react-error-boundary

Create a new file components/error-fallback.tsx:

components/error-fallback.tsx
"use client";
import type { FallbackProps } from "react-error-boundary";
import { FailureCard } from "@/components/failure-card";
import { getErrorInfo } from "@/lib/utils";
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
const { title, message } = getErrorInfo(error);
return (
<FailureCard title={title} message={message} onRetry={resetErrorBoundary} />
);
}

Now update app/page.tsx to wrap the Suspense boundary with an Error Boundary:

app/page.tsx
"use client";
import { Suspense } from "react";
import { useAtomRefresh } from "@effect-atom/atom-react";
import { ErrorBoundary } from "react-error-boundary";
import { Link } from "react-transition-progress/next";
import { ErrorFallback } from "@/components/error-fallback";
import { UserGridSpinner } from "@/components/user-grid-spinner";
import { UserGridSuspense } from "@/components/user-grid-suspense";
import { UserPagination } from "@/components/user-pagination";
import { UserSearchBar } from "@/components/user-search-bar";
import { usersAtom } from "@/atoms/user";
export default function HomePage() {
const refreshUsers = useAtomRefresh(usersAtom);
return (
<div className="container max-w-5xl space-y-10">
<div className="flex items-center justify-between">
<h1>Users</h1>
<Link
href="/add-user"
className="bg-accent text-foreground px-4 py-2 text-sm font-medium transition-colors hover:bg-neutral-200"
>
+ Add User
</Link>
</div>
<UserSearchBar />
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={refreshUsers}>
<Suspense fallback={<UserGridSpinner />}>
<UserGridSuspense />
</Suspense>
</ErrorBoundary>
<UserPagination />
</div>
);
}

The Error Boundary wraps the Suspense boundary. If the data fetch fails, the Error Boundary catches the error and renders ErrorFallback instead of crashing the app.

Notice that we use the useAtomRefresh hook. This hook takes an Atom and returns a function that, when called, tells the Registry to invalidate the Atom and re-run its Effect. We store this function in refreshUsers.

We then pass refreshUsers as the onReset prop of the ErrorBoundary. When the user clicks the retry button in ErrorFallback, it calls resetErrorBoundary, which does two things. First, it resets the Error Boundary’s internal state so it attempts to render its children again. Second, it triggers the onReset callback, which calls refreshUsers to re-run the usersAtom’s Effect. Without onReset, the Error Boundary would try to re-render the children, but the Atom’s failed result would still be cached. It would throw the same error again immediately.

To see the Error Boundary in action, go to services/user-service.ts and change the endpoint from /users to /usersx (an endpoint that doesn’t exist). Visit the home page. You’ll see the ErrorFallback component rendered with the error title and message. Click the retry button. Since the endpoint is still invalid, you’ll see the error again.

Handling Errors Manually

If you prefer to handle errors within the component rather than using an Error Boundary, useAtomSuspense accepts an includeFailure option.

app/page.tsx
"use client";
import { Suspense } from "react";
import { useAtomRefresh } from "@effect-atom/atom-react";
import { ErrorBoundary } from "react-error-boundary";
import { Link } from "react-transition-progress/next";
import { ErrorFallback } from "@/components/error-fallback";
import { UserGridSpinner } from "@/components/user-grid-spinner";
import { UserGridSuspense } from "@/components/user-grid-suspense";
import { UserPagination } from "@/components/user-pagination";
import { UserSearchBar } from "@/components/user-search-bar";
import { usersAtom } from "@/atoms/user";
export default function HomePage() {
const refreshUsers = useAtomRefresh(usersAtom);
return (
<div className="container max-w-5xl space-y-10">
<div className="flex items-center justify-between">
<h1>Users</h1>
<Link
href="/add-user"
className="bg-accent text-foreground px-4 py-2 text-sm font-medium transition-colors hover:bg-neutral-200"
>
+ Add User
</Link>
</div>
<UserSearchBar />
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={refreshUsers}>
<Suspense fallback={<UserGridSpinner />}>
<UserGridSuspense />
</Suspense>
</ErrorBoundary>
<UserPagination />
</div>
);
}

Now replace the code inside components/user-grid-suspense.tsx with the following:

components/user-grid-suspense.tsx
"use client";
import { useAtomRefresh, useAtomSuspense } from "@effect-atom/atom-react";
import { Cause } from "effect";
import { FailureCard } from "@/components/failure-card";
import { UserSuccessCard } from "@/components/user-success-card";
import { getErrorInfo } from "@/lib/utils";
import { usersAtom } from "@/atoms/user";
export function UserGridSuspense() {
const result = useAtomSuspense(usersAtom, { includeFailure: true });
const refresh = useAtomRefresh(usersAtom);
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
const { title, message } = getErrorInfo(error);
return <FailureCard title={title} message={message} onRetry={refresh} />;
}
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{result.value.users.map((user) => (
<UserSuccessCard key={user.id} user={user} />
))}
</div>
);
}

With includeFailure: true, the useAtomSuspense hook returns the Failure result instead of throwing. This gives you full control over how errors are displayed.

Cause.squash extracts the underlying error from the Cause. This is necessary because Effect wraps errors in a Cause type that can represent complex error scenarios like multiple failures or interruptions.

For the retry button, we use useAtomRefresh — the same hook we used in the Error Boundary approach. We refresh usersAtom because it’s the Atom that runs the Effect to fetch users’ data. Once usersAtom refreshes, usersListAtom, being a derived Atom, updates automatically.

If you still have the endpoint set to /usersx, visit the home page. You’ll see the FailureCard component rendered with the error details and a retry button, handled entirely within the component rather than by an Error Boundary.

Note

Before continuing, make sure to revert the users endpoint back to /users in services/user-service.ts.

Sign in to save progress

Stay in the loop

Get notified when new chapters are added and when this course is complete.