Skip to main content

Async Components in React: Tutorial

Async components are one of the most powerful features of React Server Components. You can write async function components that directly await database queries, API calls, and other promises. No hooks, no state management, no lifecycle complexity—just straightforward async/await in the component body. This article teaches you the patterns, error handling, and how to compose async components with Suspense for progressive rendering.

What Is an Async Component?

An async component is a React component defined as an async function. It can await promises directly in the component body and render the resolved data.

// A simple async component
async function BlogPost({ slug }) {
// Direct await in the component body—no useEffect needed
const post = await db.posts.findOne({ slug });

if (!post) {
return <div>Post not found</div>;
}

return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<time>{post.publishedAt}</time>
</article>
);
}

export default BlogPost;

This is dramatically simpler than the traditional React pattern where you'd use useEffect to fetch data on the client. The data is fetched on the server, and the component renders only after the data arrives.

Async components are by default server components—they run only on the server. You cannot have an async client component (a file with 'use client' that is also async). If you need async operations in a client context, use useEffect with a state hook or a library like React Query.

Pattern 1: Basic Async/Await

Write your component as an async function and await operations directly in the body:

async function UserProfile({ userId }) {
const user = await fetchUser(userId);
const posts = await fetchUserPosts(userId);

return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.bio}</p>
<h2>Recent Posts</h2>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}

Compare this to the traditional client-side pattern:

// Traditional: useEffect + useState (client component required)
'use client';

import { useEffect, useState } from 'react';

function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
Promise.all([fetchUser(userId), fetchUserPosts(userId)])
.then(([u, p]) => {
setUser(u);
setPosts(p);
setLoading(false);
});
}, [userId]);

if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;

return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.bio}</p>
<h2>Recent Posts</h2>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}

The async component is cleaner, simpler, and requires no state management or loading UI logic (that's handled by Suspense).

Pattern 2: Parallel Requests with Promise.all()

For independent async operations, use Promise.all() to fetch them concurrently:

async function Dashboard({ userId }) {
// All three requests start at the same time
const [user, posts, notifications] = await Promise.all([
db.users.findById(userId),
db.posts.findByAuthor(userId),
db.notifications.findByUser(userId),
]);

return (
<div>
<h1>Welcome, {user.name}</h1>
<div className="grid">
<section>
<h2>Your Posts ({posts.length})</h2>
<ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
</section>

<section>
<h2>Notifications ({notifications.length})</h2>
<ul>{notifications.map(n => <li key={n.id}>{n.message}</li>)}</ul>
</section>
</div>
</div>
);
}

This is much faster than sequential awaits (which would take 3x the time of one request). Parallel requests are the default pattern for independent data.

Pattern 3: Error Handling in Async Components

Wrap async operations in try-catch to handle errors gracefully:

async function BlogPost({ slug }) {
try {
const post = await db.posts.findOne({ slug });

if (!post) {
return (
<div className="error">
<h1>Post Not Found</h1>
<p>We couldn't find the post you're looking for.</p>
</div>
);
}

return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
} catch (error) {
console.error('Failed to fetch post:', error);

return (
<div className="error">
<h1>Error Loading Post</h1>
<p>Something went wrong. Please try again later.</p>
</div>
);
}
}

Errors in async components are caught by React Error Boundaries. You can also throw a custom error to trigger an error UI:

async function ProtectedContent({ userId }) {
const user = await db.users.findById(userId);

if (!user.isAdmin) {
// Throw an error to trigger error UI
throw new Error('Access denied: admin role required');
}

return <div>Admin content here</div>;
}

Pattern 4: Combining Async Components with Suspense

Use Suspense boundaries to show loading UI while async components are fetching:

import { Suspense } from 'react';

function HomePage() {
return (
<div>
<header>
<h1>Blog</h1>
</header>

<Suspense fallback={<div className="skeleton">Loading posts...</div>}>
<BlogPostList />
</Suspense>

<Suspense fallback={<div className="skeleton">Loading sidebar...</div>}>
<Sidebar />
</Suspense>
</div>
);
}

async function BlogPostList() {
const posts = await db.posts.findAll();

return (
<div className="posts">
{posts.map(post => (
<div key={post.id} className="post-card">
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</div>
))}
</div>
);
}

async function Sidebar() {
const recommendations = await db.recommendations.getLatest();

return (
<aside className="sidebar">
<h2>Recommended</h2>
<ul>
{recommendations.map(rec => (
<li key={rec.id}>{rec.title}</li>
))}
</ul>
</aside>
);
}

The page renders the header immediately, shows "Loading posts..." and "Loading sidebar..." placeholders, and updates the UI as each async component resolves. This creates a smooth, progressive rendering experience.

Pattern 5: Passing Data Between Async Components

One async component can call another, passing its resolved data as props:

async function HomePage() {
// Fetch the user on the server
const user = await db.users.findById(userId);

// Pass the user data to another async component
return (
<div>
<UserHeader user={user} />
<UserContent user={user} />
</div>
);
}

async function UserHeader({ user }) {
// Fetch additional data based on the user
const activity = await db.activity.findByUser(user.id);

return (
<header>
<h1>{user.name}</h1>
<p>Last active: {activity.lastSeen}</p>
</header>
);
}

async function UserContent({ user }) {
// Another async operation
const posts = await db.posts.findByAuthor(user.id);

return (
<main>
<h2>{posts.length} posts</h2>
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
</main>
);
}

Data flows from parent to child as props. Each async component is independently fetching data, creating a waterfall pattern. For better performance, fetch independent data at the parent level and pass it to children.

Pattern 6: Avoiding Waterfalls

A waterfall occurs when one component waits for another's data before fetching its own. To avoid this, fetch all independent data at the top level and pass it down:

// ❌ Waterfall: UserContent waits for UserHeader
async function HomePage() {
return (
<div>
<UserHeader userId={userId} />
<UserContent userId={userId} />
</div>
);
}

async function UserHeader({ userId }) {
const user = await db.users.findById(userId); // Wait for this
return <header>{user.name}</header>;
}

async function UserContent({ userId }) {
const user = await db.users.findById(userId); // Then fetch same user again
const posts = await db.posts.findByAuthor(user.id); // Finally fetch posts
return <main>{posts}</main>;
}

// ✅ Parallel: fetch user once at the top
async function HomePage() {
const user = await db.users.findById(userId);

return (
<div>
<UserHeader user={user} />
<UserContent user={user} />
</div>
);
}

async function UserHeader({ user }) {
return <header>{user.name}</header>;
}

async function UserContent({ user }) {
const posts = await db.posts.findByAuthor(user.id);
return <main>{posts}</main>;
}

In the parallel version, all independent fetches (user, posts) can start at the same time.

Pattern 7: Dynamic Data with Search and Filters

Async components are useful for dynamic pages that fetch based on query parameters:

async function SearchResults({ searchParams }) {
const { q, category } = searchParams; // Query parameters from the URL

if (!q) {
return <div>Enter a search term to begin.</div>;
}

const results = await db.search({
query: q,
category: category || undefined,
});

return (
<div>
<h1>Results for "{q}"</h1>
<p>{results.length} matches found</p>
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
}

This is used in Next.js App Router as a page component. When the URL changes (e.g., /search?q=react), the component re-fetches with the new parameters.

Key Takeaways

  • Async components are simpler than useEffect + useState for data fetching; write async/await directly in the component body.
  • Use Promise.all() for parallel requests; avoid waterfalls where one component waits for another's data.
  • Handle errors with try-catch; throw custom errors to trigger error boundaries.
  • Combine async components with Suspense for progressive rendering and skeleton screens.
  • Fetch independent data at the parent level and pass it to children as props to maximize parallelism.

Frequently Asked Questions

Can I use async/await in a client component?

No. The 'use client' directive marks a component as a client component, which must use useEffect for async operations. Async components are server-only. If you need async operations in the browser, use useEffect + useState or a data-fetching library.

What happens if an async component throws an error?

The error is caught by React's Error Boundary. If you don't have an error boundary, the page will show an error. In Next.js, the default error boundary renders a generic error UI. You can create a custom error boundary or error page.

How do I test an async component?

Write tests that mock the async functions and verify the rendered output. Use a testing library like Vitest or Jest with mocking utilities.

Can an async component have children?

Yes. An async component can render other components (server or client) as children.

Does an async component re-fetch data on every request?

By default, yes. Each page request runs the component and executes all async operations. You can cache data using next.revalidate or cache: 'no-store' in fetch() calls to control caching behavior.

Further Reading