The use() Hook in Practice: Streaming Data from Server to Client Components
Step 21 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
In Step 20, we refactored pagination from useEffect to a Server Component with await. We mentioned the use() hook but didn't use it — because that page didn't need client-side interactivity.
In this step, we build a feature that does need both: server-fetched data and client interactivity. This is exactly where the use() hook shines.
The feature: When viewing a blog post, a "More by this author" section appears at the bottom. It shows the author's other posts in a collapsible list that the user can expand or collapse.
Why use() is the right tool here:
- The component needs a
useStatetoggle (expand/collapse) — so it must be a Client Component - But the data (author's other posts) comes from a Prisma database query — which only runs on the server
- The
use()hook bridges the two: the Server Component starts the query and passes the unresolved promise to the Client Component, which reads the result withuse()
Files you will create or modify:
| File | Action |
|---|---|
app/posts/[id]/AuthorPosts.tsx | Create — Client Component using use() |
app/posts/[id]/page.tsx | Modify — start query, pass promise, wrap in Suspense |
Table of Contents
- When use() Is the Right Choice
- The Data Flow
- Step-by-Step Implementation
- How It Works Under the Hood
- What Would Happen Without use()?
- Summary & Key Takeaways
When use() Is the Right Choice
In Step 20, we established this decision table:
| Pattern | When to use |
|---|---|
await in Server Component | Data needed for rendering, no client interactivity |
use(promise) in Client Component | Server starts fetch, client needs data + interactivity |
useEffect in Client Component | Side effects: subscriptions, WebSockets, timers |
The "More by this author" section needs:
- Data from the server — query the database for the author's other posts
- Client interactivity — a show/hide toggle button (
useState)
This combination is precisely what use() was designed for. The Server Component starts the query (it has access to Prisma), and the Client Component reads the result (it has access to useState).
Cross-reference: For the theory behind
use()— how it throws promises internally, how Suspense catches them, and how it compares touseEffect— see the React tutorial sections on "Theuse()Hook" and "use()a Promise".
The Data Flow
Here is the architecture:
Server Component: PostPage
├── Fetches the current post (await)
├── Starts a query for the author's other posts (NO await — creates a promise)
├── Renders the post content
└── <Suspense fallback="Loading...">
└── <AuthorPosts promise={authorPostsPromise} />
└── Client Component: reads promise with use()
└── useState for expand/collapse toggle
The key detail is in the second step: the Server Component calls prisma.post.findMany() but does not await it. Instead, it passes the raw promise to the Client Component. This means:
- Next.js streams the post content to the browser immediately — the user sees the post right away
- The database query for "more posts" runs in parallel, not blocking the main content
- When the query finishes, Next.js streams the author posts section into the page
Step-by-Step Implementation
Step 1: Create the AuthorPosts Client Component
Create a new file app/posts/[id]/AuthorPosts.tsx:
"use client";
import { use, useState } from "react";
import Link from "next/link";
type Post = {
id: number;
title: string;
createdAt: Date;
};
type Props = {
promise: Promise<Post[]>;
authorName: string;
};
export default function AuthorPosts({ promise, authorName }: Props) {
const posts = use(promise);
const [expanded, setExpanded] = useState(false);
if (posts.length === 0) {
return null;
}
const displayed = expanded ? posts : posts.slice(0, 3);
return (
<div className="mt-8 rounded-lg border border-gray-200 bg-gray-50 p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900">
More by {authorName}
</h3>
<ul className="space-y-2">
{displayed.map((post) => (
<li key={post.id}>
<Link
href={`/posts/${post.id}`}
className="text-teal-600 hover:text-teal-700 hover:underline"
>
{post.title}
</Link>
<span className="ml-2 text-xs text-gray-400">
{new Date(post.createdAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
{posts.length > 3 && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-3 text-sm font-medium text-teal-600 hover:text-teal-700"
>
{expanded ? "Show less" : `Show all ${posts.length} posts`}
</button>
)}
</div>
);
}
Let's break this down:
use(promise) — Reading the Promise
const posts = use(promise);
This is the core of use(). The promise was created in the Server Component (a Prisma query). When use() encounters an unresolved promise, it suspends the component — React shows the Suspense fallback. Once the promise resolves, React re-renders with the data.
useState for Interactivity
const [expanded, setExpanded] = useState(false);
This is why we need a Client Component. The expand/collapse toggle is a piece of browser-side state. Server Components cannot use useState.
Why Both Must Coexist
Notice that use() and useState sit side by side in the same component. This is the power of the use() hook: it lets a Client Component consume server-fetched data without needing useEffect, an API route, or manual loading states.
The Type Definition
type Post = {
id: number;
title: string;
createdAt: Date;
};
Unlike Step 12 where data traveled through fetch() and JSON (which converts Date objects to strings), the use() pattern passes data through React's own serialization layer — which preserves Date objects. So createdAt is typed as Date, not string. The new Date() wrapper in the template is still harmless if present, but not strictly necessary here.
Early Return for Empty State
if (posts.length === 0) {
return null;
}
If the author has no other posts, the section doesn't render at all.
Step 2: Update the Post Detail Page
Modify app/posts/[id]/page.tsx — add a Suspense-wrapped AuthorPosts at the bottom:
import { Suspense } from "react";
import { auth } from "@/auth";
import prisma from "@/lib/prisma";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import DeleteButton from "./DeleteButton";
import AuthorPosts from "./AuthorPosts";
type Props = {
params: Promise<{ id: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await prisma.post.findUnique({
where: { id: Number(id) },
});
return {
title: post
? `${post.title} — NextJs-FullStack-App-Blog-APP`
: "Post Not Found",
};
}
export default async function PostPage({ params }: Props) {
const { id } = await params;
const session = await auth();
const post = await prisma.post.findUnique({
where: { id: Number(id) },
include: { author: true },
});
if (!post) {
notFound();
}
const isAuthor = session?.user?.email === post.author?.email;
// Start the query but do NOT await — pass the promise to the client
const authorPostsPromise = prisma.post.findMany({
where: {
authorId: post.authorId,
id: { not: post.id },
},
orderBy: { createdAt: "desc" },
select: { id: true, title: true, createdAt: true },
});
return (
<>
<Link
href="/posts"
className="inline-block mb-6 text-sm text-teal-600 hover:text-teal-700"
>
← Back to all posts
</Link>
<article className="p-8 bg-white rounded-lg shadow-md">
<div className="flex items-start justify-between mb-2">
<h2 className="text-3xl font-bold text-gray-900">{post.title}</h2>
{isAuthor && (
<div className="flex items-center gap-2">
<Link
href={`/posts/${post.id}/edit`}
className="px-3 py-1 text-sm text-teal-600 border border-teal-300 rounded hover:bg-teal-50"
>
Edit
</Link>
<DeleteButton postId={post.id} />
</div>
)}
</div>
<p className="mb-6 text-sm text-gray-500">
By {post.author?.name ?? "Unknown"} ·{" "}
{new Date(post.createdAt).toLocaleDateString()}
</p>
<div className="prose text-gray-700 max-w-none">
<p>{post.content}</p>
</div>
</article>
<Suspense
fallback={
<p className="mt-8 text-sm text-gray-400">
Loading more posts...
</p>
}
>
<AuthorPosts
promise={authorPostsPromise}
authorName={post.author?.name ?? "Unknown"}
/>
</Suspense>
</>
);
}
Let's focus on the key changes:
Starting the Query Without Awaiting
const authorPostsPromise = prisma.post.findMany({
where: {
authorId: post.authorId,
id: { not: post.id },
},
orderBy: { createdAt: "desc" },
select: { id: true, title: true, createdAt: true },
});
No await keyword. This line starts the database query and returns a promise. The query runs in the background while the rest of the page renders.
The where clause excludes the current post (id: { not: post.id }) and fetches only posts by the same author. The select limits the fields — we only need id, title, and createdAt, not the full content.
Passing the Promise Through Suspense
<Suspense
fallback={
<p className="mt-8 text-sm text-gray-400">
Loading more posts...
</p>
}
>
<AuthorPosts
promise={authorPostsPromise}
authorName={post.author?.name ?? "Unknown"}
/>
</Suspense>
The promise is passed as a prop to AuthorPosts. When AuthorPosts calls use(promise) and the promise hasn't resolved yet, React shows the Suspense fallback. The main post content above is already visible to the user — only this section is loading.
What to Expect
To see the feature in action, the same author must have at least 2 posts. The section only appears when the author has other posts besides the one you're viewing — if the author has only 1 post, the component returns null and nothing renders.
Test it like this:
- Log in and create 2 or more posts with the same account
- Navigate to any one of those posts (e.g.,
/posts/3) - Below the post content, you should see a gray box titled "More by [your name]" listing your other posts as teal links
- If the author has 4+ other posts, only the first 3 are shown with a "Show all N posts" button — click it to expand the full list, click "Show less" to collapse
If you only see the post without any "More by..." section, check that the logged-in user has created multiple posts.
How It Works Under the Hood
Here's the complete flow from request to rendered page:
1. Browser requests /posts/5
2. Server renders PostPage:
├── await prisma.post.findUnique(5) ← blocks until done
├── authorPostsPromise = prisma.post.findMany(...) ← starts, does NOT block
├── Renders: Back link + article + "Loading more posts..."
└── Streams this HTML to the browser immediately
3. Browser shows: Post content + "Loading more posts..."
4. Author posts query completes on the server:
└── React renders AuthorPosts with the data
└── use(promise) → resolved → posts array available
└── useState(false) → collapsed by default
5. Server streams the AuthorPosts HTML to replace the fallback
6. Browser shows: Post content + "More by Author" section
└── User can now click "Show all" / "Show less" (client-side toggle)
The user sees the post content instantly (step 3). The "More by this author" section appears moments later (step 5) without a page reload or any client-side fetch.
Why Not Just await Both Queries?
You could write:
const post = await prisma.post.findUnique({ ... });
const authorPosts = await prisma.post.findMany({ ... });
This works — but the second await blocks the page. The user sees nothing until both queries complete. With the promise + Suspense pattern, the main content streams first and the secondary content follows.
What Would Happen Without use()?
Without use(), you'd need the useEffect pattern from Step 12:
"use client";
export default function AuthorPosts({ authorId, currentPostId }) {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
fetch(`/api/author-posts?authorId=${authorId}&exclude=${currentPostId}`)
.then((res) => res.json())
.then((data) => {
setPosts(data);
setLoading(false);
});
}, [authorId, currentPostId]);
if (loading) return <p>Loading...</p>;
// ... render posts
}
This requires:
- A new API route (
/api/author-posts) just to query the database - Manual loading state (
loading,setLoading) - A client-side fetch after the page has already loaded (waterfall)
- No streaming — the browser waits for JS to load, then fires the fetch
The use() version eliminates all of this. The query runs on the server, streams to the client, and the Client Component gets the data directly — no API route, no loading state, no waterfall.
useEffect approach | use() approach | |
|---|---|---|
| API route needed | Yes | No |
| Loading state code | Manual (useState) | Declarative (Suspense) |
| When data loads | After JS loads + executes | Streamed from server |
| Network requests | Page HTML + API fetch | Single streamed response |
| SEO for author posts | No (loaded via JS) | Yes (included in HTML stream) |
Summary & Key Takeaways
| Concept | Details |
|---|---|
When to use use() | When a Client Component needs server-fetched data and client interactivity (hooks, event handlers) |
| The pattern | Server Component starts query (no await) → passes promise as prop → Client Component reads with use() → wrapped in Suspense |
use(promise) | Suspends the component until the promise resolves — React shows the Suspense fallback in the meantime |
| Streaming benefit | Main content renders immediately; the use() section streams in later without blocking |
Compared to await | await blocks the entire page until the query completes; promise + use() lets the rest of the page render first |
Compared to useEffect | No API route needed, no manual loading state, no client-side fetch waterfall, SEO-friendly |
select in Prisma | Use select to fetch only the fields you need — reduces data transfer and improves performance |
| File impact | One new Client Component + one modified Server Component — minimal changes |