The use() Hook in Practice: Streaming Data from Server to Client Components

Step 21 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

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 useState toggle (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 with use()

Files you will create or modify:

FileAction
app/posts/[id]/AuthorPosts.tsxCreate — Client Component using use()
app/posts/[id]/page.tsxModify — start query, pass promise, wrap in Suspense

Table of Contents

  1. When use() Is the Right Choice
  2. The Data Flow
  3. Step-by-Step Implementation
  4. How It Works Under the Hood
  5. What Would Happen Without use()?
  6. Summary & Key Takeaways

When use() Is the Right Choice

↑ Index

In Step 20, we established this decision table:

PatternWhen to use
await in Server ComponentData needed for rendering, no client interactivity
use(promise) in Client ComponentServer starts fetch, client needs data + interactivity
useEffect in Client ComponentSide 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 to useEffect — see the React tutorial sections on "The use() Hook" and "use() a Promise".


The Data Flow

↑ Index

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:

  1. Next.js streams the post content to the browser immediately — the user sees the post right away
  2. The database query for "more posts" runs in parallel, not blocking the main content
  3. When the query finishes, Next.js streams the author posts section into the page

Step-by-Step Implementation

↑ Index

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"
      >
        &larr; 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"} &middot;{" "}
          {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:

  1. Log in and create 2 or more posts with the same account
  2. Navigate to any one of those posts (e.g., /posts/3)
  3. Below the post content, you should see a gray box titled "More by [your name]" listing your other posts as teal links
  4. 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

↑ Index

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()?

↑ Index

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 approachuse() approach
API route neededYesNo
Loading state codeManual (useState)Declarative (Suspense)
When data loadsAfter JS loads + executesStreamed from server
Network requestsPage HTML + API fetchSingle streamed response
SEO for author postsNo (loaded via JS)Yes (included in HTML stream)

Summary & Key Takeaways

↑ Index

ConceptDetails
When to use use()When a Client Component needs server-fetched data and client interactivity (hooks, event handlers)
The patternServer 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 benefitMain content renders immediately; the use() section streams in later without blocking
Compared to awaitawait blocks the entire page until the query completes; promise + use() lets the rest of the page render first
Compared to useEffectNo API route needed, no manual loading state, no client-side fetch waterfall, SEO-friendly
select in PrismaUse select to fetch only the fields you need — reduces data transfer and improves performance
File impactOne new Client Component + one modified Server Component — minimal changes