Error handling and retry with React Server Components

React Server Components are a game-changer when it comes to building large web applications without sending megabytes of JavaScript to the client. They allow you to render components on the server and stream them to the client, which can significantly improve the performance of your application.

However, React Server Components can throw errors, just like regular React components. In this article, we'll explore how to handle and recover from errors in React Server Components.

Error boundaries

In React, you can use error boundaries to catch errors that occur during rendering, in lifecycle methods, or in constructors of the whole tree below them. An error boundary is a React component that catches JavaScript errors anywhere in its child component tree and logs those errors, displaying a fallback UI instead of crashing the entire application.

To create an error boundary in React, you need to define a component that implements the componentDidCatch lifecycle method. This method is called whenever an error occurs in the component tree below the error boundary.

Here's an example of an error boundary component:

ErrorBoundary.tsx
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

componentDidCatch(error, errorInfo) {
this.setState({ hasError: true });
console.error(error, errorInfo);
}

render() {
if (this.state.hasError) {
return <div>Something went wrong.</div>;
}

return this.props.children;
}
}
ErrorBoundary.tsx
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

componentDidCatch(error, errorInfo) {
this.setState({ hasError: true });
console.error(error, errorInfo);
}

render() {
if (this.state.hasError) {
return <div>Something went wrong.</div>;
}

return this.props.children;
}
}

Alternatively, you can use the ErrorBoundary component from the react-error-boundary library, which provides a slightly more robust implementation of error boundaries, including support for error recovery and retrying rendering. Here's how we might use that on an RSC-rendered page:

page.tsx
'use client'
import { ErrorBoundary } from 'react-error-boundary'

export default function PageWithBoundary() {
return (
<>
<p>
This page demonstrates what happens when an error is thrown in a
component with an explicit error boundary.
</p>

<ErrorBoundary fallback={<ErrorFallback />}>
<ErrorComponent />
</ErrorBoundary>
</>
)
}

function ErrorComponent() {
throw new Error('Error thrown in component')

return 'This will never be rendered'
}

function ErrorFallback() {
return (
<div className="text-red-700">There was an error with this content</div>
)
}
page.tsx
'use client'
import { ErrorBoundary } from 'react-error-boundary'

export default function PageWithBoundary() {
return (
<>
<p>
This page demonstrates what happens when an error is thrown in a
component with an explicit error boundary.
</p>

<ErrorBoundary fallback={<ErrorFallback />}>
<ErrorComponent />
</ErrorBoundary>
</>
)
}

function ErrorComponent() {
throw new Error('Error thrown in component')

return 'This will never be rendered'
}

function ErrorFallback() {
return (
<div className="text-red-700">There was an error with this content</div>
)
}

When we render this page, we'll end up seeing something like this:

Error boundary in action
Our rendered page with an error boundary in place

This is useful as it allows the rest of our page to render and be usable, even if a component or two throw errors. It allows us to inform the user that something went wrong, at which point they're likely to want to hit the refresh button. But there's a better way...

Retrying rendering

Wouldn't it be cool if we could allow the user to retry rendering the component that errored out? With React Server Components, we can! Kind of.

Our ideal solution here would be to allow the rest of the page to render and be interactive, while the errored component is replaced with a button that allows the user to retry rendering it. If we're going to show the user an error, it's best not to take down the whole page with it, and to give them an easy way to recover from it.

Let's see how we might implement this:

page.tsx
import { ErrorBoundary } from 'react-error-boundary'
import ErrorFallback from './ErrorFallback'

export default function ResettablePage() {
return (
<>
<p>
This page has a component with a 50% chance of throwing an error. If it
does, a Reset button will appear that you can click to reset the
component.
</p>
<p>
This is useful for when you want to give the user a way to recover from
an error without having to refresh the entire page. Refresh the page a
few times if you don't get the error immediately.
</p>

<ErrorBoundary FallbackComponent={ErrorFallback}>
<ErrorComponent />
</ErrorBoundary>
</>
)
}

async function ErrorComponent() {
// Simulate a delay so we can see the Reset button spinning
await new Promise((resolve) => setTimeout(resolve, 1000))

if (Math.random() > 0.5) {
throw new Error('Error thrown in component')
}

return (
<p className="border border-blue-700 p-4">
This has a 50% chance of throwing an error, but this time it rendered
fine.
</p>
)
}

page.tsx
import { ErrorBoundary } from 'react-error-boundary'
import ErrorFallback from './ErrorFallback'

export default function ResettablePage() {
return (
<>
<p>
This page has a component with a 50% chance of throwing an error. If it
does, a Reset button will appear that you can click to reset the
component.
</p>
<p>
This is useful for when you want to give the user a way to recover from
an error without having to refresh the entire page. Refresh the page a
few times if you don't get the error immediately.
</p>

<ErrorBoundary FallbackComponent={ErrorFallback}>
<ErrorComponent />
</ErrorBoundary>
</>
)
}

async function ErrorComponent() {
// Simulate a delay so we can see the Reset button spinning
await new Promise((resolve) => setTimeout(resolve, 1000))

if (Math.random() > 0.5) {
throw new Error('Error thrown in component')
}

return (
<p className="border border-blue-700 p-4">
This has a 50% chance of throwing an error, but this time it rendered
fine.
</p>
)
}

Ok so we have have a page that contains a bunch of content, plus a component that has a 50% chance of throwing an error. If it does, we'll show a Reset button that the user can click to retry rendering the component.

We used the ErrorBoundary component from react-error-boundary to catch the error and display the ErrorFallback component when an error occurs. The ErrorFallback component contains a button that allows the user to retry rendering the component. Here's what the ErrorFallback component looks like:

ErrorFallback.tsx
'use client'

import { startTransition, useState } from 'react'
import { useRouter } from 'next/navigation'

import Spinner from './Spinner'

export default function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error
resetErrorBoundary: () => void
}) {
const router = useRouter()

//tracks the state of our reset button
const [isResetting, setIsResetting] = useState(false)

function retry() {
setIsResetting(true)

startTransition(() => {
router.refresh()
resetErrorBoundary()
setIsResetting(false)
})
}

return (
<div className="border border-orange-700 p-4 text-orange-700">
<p className="m-0 mb-2 p-0">There was an error loading this component</p>
<button
onClick={() => retry()}
disabled={isResetting}
className="button inline-flex items-center gap-4 rounded-md border bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
{isResetting ? <Spinner /> : null}
Retry
</button>
</div>
)
}
ErrorFallback.tsx
'use client'

import { startTransition, useState } from 'react'
import { useRouter } from 'next/navigation'

import Spinner from './Spinner'

export default function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error
resetErrorBoundary: () => void
}) {
const router = useRouter()

//tracks the state of our reset button
const [isResetting, setIsResetting] = useState(false)

function retry() {
setIsResetting(true)

startTransition(() => {
router.refresh()
resetErrorBoundary()
setIsResetting(false)
})
}

return (
<div className="border border-orange-700 p-4 text-orange-700">
<p className="m-0 mb-2 p-0">There was an error loading this component</p>
<button
onClick={() => retry()}
disabled={isResetting}
className="button inline-flex items-center gap-4 rounded-md border bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
{isResetting ? <Spinner /> : null}
Retry
</button>
</div>
)
}

A few things to note here:

  • The ErrorFallback component is a client component (so is its parent - the ErrorBoundary component that we used)
  • We use router.refresh() to retry the rendering of the component that errored out. This actually re-renders the whole page, but to the user it looks like only the errored component is being re-rendered
  • We need to wrap the router.refresh() call in the new startTransition API because router.refresh() is a long-running operation that does not return a Promise, so we can't await it
  • We used an isResetting state variable to allow us to show a spinner while the component is being re-rendered

When we render this page, we'll see something like this:

This is an iframe pointing to https://rsc-examples.edspencer.net/examples/errors/reset-boundary

That's a fully interactive iframe pointing to a live example on my RSC Examples site. You had a 50% chance of seeing an error, but you can hit the little refresh icon above the iframe if you got lucky/unlucky enough not to see an error.

Now, when you click the blue Retry button, our retry function within the ErrorFallback component will be called. This will set the isResetting state to true, refresh the page, reset the error boundary, and then set isResetting back to false. This will cause the ErrorComponent to be re-rendered, and with a bit of luck, it won't throw an error this time.

What actually happened here is that we reloaded the whole page, so it's not as surgical as it looks (the hint is in the router.refresh() call...). However, from the user's perspective, it feels very much like just this one single component is being retried, and existing state such as form input is maintained. This is significantly better than either crashing the whole page or forcing the user to refresh the whole page.

Conclusion

The combination of error boundaries and retrying rendering with React Server Components allows you to build robust web applications that can recover from errors gracefully. By catching errors and displaying a fallback UI, you can prevent your application from crashing and provide a better user experience.

Share Post:

What to Read Next

If you found this post insightful, you might enjoy exploring Promises across the void: Streaming data with RSC, which delves into managing asynchronous rendering in React Server Components. Additionally, Decoding React Server Component Payloads provides a detailed look at how Next.js handles server-client communication through React Server Components, enhancing your understanding of server-side rendering intricacies.