Type something to search...
next.js Building interactive apps

next.js Building interactive apps

Every time a user clicks something in a Next.js app that triggers a Server Function, there's a gap. The network round-trip takes an unknown amount of time — could be 40ms, could be 800ms on a bad connection — and during that gap the UI has to decide what to show. Do nothing, and the button looks broken. Show a spinner for everything, and the app feels sluggish even when it isn't. Get it right, and the interface feels like it's keeping pace with the user instead of making them wait on it.

This is one of those areas where the App Router gives you the primitives (useOptimistic, useTransition, useActionState, Suspense) but doesn't tell you which one to reach for in which situation. Most teams either overuse useState and manual loading flags everywhere, or they discover useOptimistic and start optimistically updating things that don't need it. The goal of this article is to build the muscle memory: given a specific kind of interaction, which primitive actually solves it.

I'm going to walk through this the same way Next.js's own guide does — building up a small task board app called Taskboard, one interaction at a time. Each step introduces exactly one problem and exactly one tool to fix it. By the end you'll have a mental checklist you can run through for any interactive feature: is this slow initial data, an instant toggle, a list mutation, a form, or a navigation? Each of those has a different right answer.

Why this matters beyond "it feels nicer"

It's tempting to file responsive UI feedback under polish — nice to have, not essential. But there's a harder argument for taking it seriously: these patterns directly move your Core Web Vitals.

Streaming slow reads behind <Suspense> lowers First Contentful Paint and Largest Contentful Paint, because the page shell can paint before the slow query resolves instead of blocking on it. Optimistic UI and transitions lower Interaction to Next Paint, because the visible result of a click renders on the same frame the click happens, rather than waiting for a server round-trip. Prefetching the next route makes the destination page's FCP, LCP, and INP look near-instant too, since most of the work already happened before the click landed.

None of this replaces the usual INP hygiene — you still need to ship less client JavaScript and avoid blocking round-trips during interactions. But if you're already doing that and your app still feels laggy, the patterns below are usually the missing piece.

Setting the scene: an app with no feedback at all

Picture a task board — three columns (Todo, In Progress, Done), draggable cards, a priority indicator you can click to cycle through low/medium/high, label filter chips, a comment thread per task, and a "New Task" dialog. It's a Server Component app: it reads tasks and comments straight from a database, no <Suspense> boundaries, no caching. When something needs to change, a Client Component calls a Server Function, and that function calls refresh() to force the server to re-render with the latest data.

// features/task/task-actions.ts
"use server";

import { refresh } from "next/cache";
import { PRIORITY_CYCLE } from "@/lib/data";
import { getTaskById, updateTaskPriority } from "@/lib/db";

export async function cyclePriority(taskId: string) {
  const task = await getTaskById(taskId);
  if (!task) return null;
  const newPriority = PRIORITY_CYCLE[task.priority];
  await updateTaskPriority(taskId, newPriority);
  refresh();
  return newPriority;
}

This works. It's also exactly the kind of app that feels broken to use — every click is a small stall. We're going to fix that one interaction pattern at a time.

Step 1: Stop blocking the whole page on the slowest read

The task detail page needs two things: the task itself, and its comments. The naive version awaits both up front:

// app/task/[id]/page.tsx
import { getTask } from "@/features/task/task-queries";
import { TaskDetail } from "@/features/task/components/task-detail";
import { CommentSection } from "@/features/task/components/comment-section";

export default async function TaskPage({ params }) {
  const { id } = await params;
  const task = await getTask(id);

  return (
    <div>
      <TaskDetail task={task} />
      <CommentSection taskId={id} />
    </div>
  );
}

The comments query doesn't even start until getTask finishes, and nothing paints until both are done. If comments are slow — a big thread, a slow join — the whole page sits blank waiting on data the user doesn't even need yet to see the task title.

The fix is to split the reads into their own components, each responsible for awaiting its own data, and wrap them in <Suspense>:

// app/task/[id]/page.tsx
import { Suspense } from "react";
import {
  TaskDetail,
  TaskDetailSkeleton,
} from "@/features/task/components/task-detail";
import {
  CommentSection,
  CommentSectionSkeleton,
} from "@/features/task/components/comment-section";

export default function TaskPage({ params }) {
  return (
    <div>
      <Suspense fallback={<TaskDetailSkeleton />}>
        {params.then(({ id }) => (
          <>
            <TaskDetail id={id} />
            <Suspense fallback={<CommentSectionSkeleton />}>
              <CommentSection taskId={id} />
            </Suspense>
          </>
        ))}
      </Suspense>
    </div>
  );
}

The page component itself becomes synchronous — no top-level await — which is the whole trick. Calling params.then() inline inside JSX, rather than awaiting it at the top of the function, is what keeps the page function synchronous while still letting the outer <Suspense> cover the params resolution and the task read. Once TaskDetail resolves, React reveals the header and moves on to the nested boundary for comments, which shows its own skeleton until getComments resolves.

The practical note the docs won't spell out: this pattern only pays off if your skeletons are genuinely fast to render and don't themselves trigger a layout shift when the real content swaps in. A skeleton that's a different height than the real content will cause a visible jump the moment the streamed content arrives — which undoes some of the perceived-performance win you were going for. Match your skeleton dimensions to the real content's typical size.

Step 2: Make a toggle feel instant with useOptimistic

Each task card has a priority dot you click to cycle through low → medium → high. The naive version calls the Server Function directly from the click handler:

"use client";

import { cyclePriority } from "@/features/task/task-actions";

export function TaskCard({ id, priority }) {
  return (
    <button onClick={() => cyclePriority(id)} className={priorityDot[priority]}>
      {priority}
    </button>
  );
}

Click it, and the Server Function fires — but the priority prop the button renders doesn't change until the next server render arrives. Visually, nothing happens for a beat, then it jumps. This is the single most common bug pattern I see in Next.js apps that call Server Functions directly from event handlers: people assume the mutation and the re-render are the same event. They aren't.

useOptimistic paired with useTransition fixes this:

"use client";

import { useOptimistic, useTransition } from "react";
import { cyclePriority } from "@/features/task/task-actions";
import { PRIORITY_CYCLE } from "@/lib/data";

export function TaskCard({ id, priority }) {
  const [optimisticPriority, setOptimisticPriority] = useOptimistic(priority);
  const [, startTransition] = useTransition();

  function handlePriority() {
    startTransition(async () => {
      setOptimisticPriority(PRIORITY_CYCLE[optimisticPriority]);
      await cyclePriority(id);
    });
  }

  return (
    <button
      onClick={handlePriority}
      className={priorityDot[optimisticPriority]}
    >
      {optimisticPriority}
    </button>
  );
}

setOptimisticPriority updates the rendered value on the current frame, before the Server Function has even started running. Reading from optimisticPriority — not the priority prop — is what makes rapid double-clicks cycle correctly instead of getting stuck reading a stale value from a closure captured before the first click resolved. When the transition finishes and a fresh server-rendered priority prop arrives, the optimistic value quietly reverts to match it (and if the two agree, nothing visibly changes).

One thing worth calling out that's easy to miss: if the Server Function inside a transition throws, React forwards that error to the nearest error boundary automatically. You don't need a manual try/catch around every optimistic mutation — which is a relief, because wrapping every single one by hand gets tedious fast in a real app with dozens of these buttons.

Step 3: Pending feedback for a filter, without prop drilling

The board has filter chips — Design, Frontend, Backend — that update a label query param and re-render the filtered list. Calling router.push() directly starts the navigation but gives you nothing to show while it's in flight:

"use client";

import { useRouter, useSearchParams } from "next/navigation";

export function LabelFilter() {
  const router = useRouter();
  const searchParams = useSearchParams();

  function handleFilter(value: string | null) {
    const params = new URLSearchParams(searchParams.toString());
    if (value) params.set("label", value);
    else params.delete("label");
    router.push(`/?${params.toString()}`);
  }

  // render chips with onClick={handleFilter}
}

The interesting design decision here is where the pending state lives. LabelFilter knows what to navigate to, but it shouldn't have to know how to show pending feedback — that's a concern a reusable ChipGroup component should own on its own, so every consumer of chips gets it for free.

// features/task/components/label-filter.tsx
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { ChipGroup } from "@/components/ui/chip-group";

const labels = [
  { label: "Design", value: "design" },
  { label: "Frontend", value: "frontend" },
  { label: "Backend", value: "backend" },
];

export function LabelFilter() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const current = searchParams.get("label") ?? null;

  function filterAction(value: string | null) {
    const params = new URLSearchParams(searchParams.toString());
    if (value) params.set("label", value);
    else params.delete("label");
    router.push(`/?${params.toString()}`);
  }

  return (
    <ChipGroup items={labels} value={current} changeAction={filterAction} />
  );
}
// components/ui/chip-group.tsx
"use client";

import { startTransition, useOptimistic } from "react";

export function ChipGroup({ items, value, changeAction }) {
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
  const [isPending, setIsPending] = useOptimistic(false);

  function handleClick(newValue) {
    startTransition(async () => {
      setOptimisticValue(newValue);
      setIsPending(true);
      await changeAction(newValue);
    });
  }

  return (
    <div className="flex gap-1.5" data-pending={isPending ? "" : undefined}>
      {items.map((item) => (
        <button
          key={item.value}
          onClick={() =>
            handleClick(item.value === optimisticValue ? null : item.value)
          }
          className={item.value === optimisticValue ? "active" : ""}
        >
          {item.label}
        </button>
      ))}
    </div>
  );
}

Notice the naming convention: changeAction rather than onChange. That's deliberate — React's own docs recommend naming props action or *Action specifically when the prop is meant to be run inside a transition, as a signal to whoever's reading the component later. It costs nothing and saves the next person from having to trace through the implementation to figure out whether a given callback prop is transition-aware.

The data-pending attribute on the root element is the part I'd flag as underused. Because it's just a DOM attribute, any ancestor element can react to it purely with CSS — no context, no prop threading, no lifting state up three levels. In this app, the board dims to 50% opacity while a filter transition is pending, using Tailwind's group-has-data-pending:opacity-50, without ever replacing the board with a full loading skeleton.

One caveat worth internalizing before you sprinkle data-pending and :has() everywhere: group-has-data-pending: and has-data-pending: compile down to the CSS :has() selector, and the browser re-evaluates :has() over its anchored subtree every time the watched attribute changes. That's cheap when it toggles twice per filter click, like here. It gets expensive if you anchor a broad :has() selector to something that toggles at high frequency — dragging or scrolling, for instance. For those, reach for actual client state instead of a CSS attribute hook.

Step 4: Optimistic comments that don't touch the persisted list

Submitting a comment writes to the database, but the list of comments is rendered by a Server Component — it has no way to show the new comment until the next server render arrives. The naive version uses controlled input state and awaits the mutation directly:

"use client";

import { useState } from "react";
import { addComment } from "@/features/task/task-actions";

export function CommentForm({ taskId }) {
  const [content, setContent] = useState("");

  async function handleSubmit() {
    if (!content.trim()) return;
    await addComment(taskId, content);
    setContent("");
  }

  return (
    <div>
      <input
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Write a comment..."
      />
      <button onClick={handleSubmit}>Send</button>
    </div>
  );
}

The fix splits the pending comments away from the persisted ones entirely, tracking them in a separate useOptimistic([]) array that starts empty and resets to empty once the real data catches up:

// features/task/components/optimistic-comments.tsx
"use client";

import { useOptimistic, useRef } from "react";
import { addComment } from "@/features/task/task-actions";
import { CommentCard } from "./comment-card";

export function OptimisticComments({ taskId }) {
  const [pendingComments, setPendingComments] = useOptimistic([]);
  const formRef = useRef(null);

  return (
    <>
      <form
        ref={formRef}
        action={async (formData) => {
          const content = formData.get("content")?.trim();
          if (!content) return;
          formRef.current?.reset();

          const id = crypto.randomUUID();
          setPendingComments((current) => [
            {
              id,
              content,
              userName: "You",
              createdAt: new Date().toISOString(),
            },
            ...current,
          ]);

          await addComment(taskId, content);
        }}
      >
        <input name="content" placeholder="Write a comment..." required />
        <button type="submit">Send</button>
      </form>
      {pendingComments.map((comment) => (
        <CommentCard key={comment.id} comment={comment} pending />
      ))}
    </>
  );
}

Three things fire, in this exact order, inside the submit action: formRef.current?.reset() clears the input immediately via direct DOM manipulation — not a useState setter, which would be deferred until the transition finishes — then setPendingComments inserts a client-generated placeholder (with a real crypto.randomUUID(), not an array index, so React can key it stably even as the list reorders), and only then does addComment actually run.

This ordering is the part that trips people up. useOptimistic setters and direct DOM calls apply on the current frame, inside a transition, but a useState setter called in the same transition gets deferred until the transition resolves. If you swap formRef.current?.reset() for a useState-backed clear, the input will visibly sit filled-in for the duration of the request instead of clearing immediately — a subtle bug that's easy to introduce by "simplifying" this pattern later.

The server component alongside it just renders the real, persisted list — no special handling needed there:

// features/task/components/comment-section.tsx
import { getComments } from "@/features/task/task-queries";
import { CommentCard } from "./comment-card";
import { OptimisticComments } from "./optimistic-comments";

async function CommentSection({ taskId }) {
  const comments = await getComments(taskId);

  return (
    <div>
      <OptimisticComments taskId={taskId} />
      {comments.map((comment) => (
        <CommentCard key={comment.id} comment={comment} />
      ))}
    </div>
  );
}

When the mutation completes and a fresh render arrives, the pending list resets to empty and the real comment — now persisted — appears in the server list instead. To the user it reads as one smooth item, but under the hood it's a faded placeholder handing off to a real record.

Step 5: Dragging cards between columns

The board's three columns each render from a shared tasks prop. Dropping a card on a new column should move it there instantly, but the underlying prop doesn't change until the server responds. The naive version calls the mutation directly on drop and lets the card sit in its old column until the response lands:

"use client";

import { use } from "react";
import { updateStatus } from "@/features/task/task-actions";

export function Board({ tasksPromise }) {
  const tasks = use(tasksPromise);

  function handleDrop(targetStatus, taskId) {
    updateStatus(taskId, targetStatus);
  }
  // render columns from tasks
}

Here the fix uses useOptimistic with a reducer rather than a plain setter, because the state being updated is a whole list, and the update is "find this one item and change its status" rather than "replace the whole value":

"use client";

import { startTransition, use, useOptimistic } from "react";
import { toast } from "sonner";
import { updateStatus } from "@/features/task/task-actions";

export function Board({ tasksPromise }) {
  const tasks = use(tasksPromise);
  const [optimisticTasks, moveTask] = useOptimistic(
    tasks,
    (currentTasks, action: { taskId: string; status: Status }) =>
      currentTasks.map((t) =>
        t.id === action.taskId ? { ...t, status: action.status } : t,
      ),
  );

  function handleDrop(targetStatus, taskId) {
    startTransition(async () => {
      moveTask({ taskId, status: targetStatus });
      const result = await updateStatus(taskId, targetStatus);
      if (!result.success) toast.error(result.error);
    });
  }

  return (
    <div className="grid grid-cols-3 gap-4">
      {columns.map((col) => (
        <Column
          key={col.status}
          tasks={optimisticTasks.filter((t) => t.status === col.status)}
          onDrop={(taskId) => handleDrop(col.status, taskId)}
        />
      ))}
    </div>
  );
}

There's a subtlety here worth sitting with: because the reducer re-derives the optimistic list from whatever the current base tasks value is, if a background refresh lands mid-drag — someone else moved a card, or a poll fired — React re-runs the reducer against the fresh base data, and your in-flight optimistic move still applies correctly on top of it. You don't have to manually reconcile "my pending change" against "the new server truth" — the reducer pattern does that for you as a side effect of how it's structured.

Also notice this step uses the bare startTransition import rather than the useTransition hook. That's deliberate, not an oversight: useTransition's isPending flag would trigger the same board-wide fade from Step 3's data-pending CSS hook, and a global dim during a drag looks wrong — the optimistic card move already is the visual feedback. When a Server Function fails with a handled error (the task no longer exists, say), the result comes back as data rather than a throw, and the card reverts on the next render while a toast explains why.

Step 6: A form with real lifecycle — pending, reset, and close

The "New Task" dialog is where three pieces of state collide: is the submit button disabled, have the fields been cleared, and should the dialog close. Handling all three with separate useState calls gets messy fast, and worse, it's easy to get the ordering wrong — a common bug is the dialog closing before the board has the new task, because the close happens outside any transition:

"use client";

import { useState } from "react";
import { createTask } from "@/features/task/task-actions";

export function CreateTaskModal() {
  const [isOpen, setIsOpen] = useState(false);
  const [isSubmitting, setIsSubmitting] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const title = formData.get("title");
    if (!title.trim()) return;

    setIsSubmitting(true);
    await createTask({ title });
    setIsSubmitting(false);
    setIsOpen(false);
  }

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <form onSubmit={handleSubmit}>
        <input name="title" placeholder="Task title..." required />
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? "Creating..." : "Create Task"}
        </button>
      </form>
    </Dialog>
  );
}

useActionState collapses all three concerns into one hook:

"use client";

import { useActionState, startTransition, useState } from "react";
import { createTask } from "@/features/task/task-actions";

export function CreateTaskModal() {
  const [isOpen, setIsOpen] = useState(false);

  const [{ key }, formAction, isPending] = useActionState(
    async (prev, formData) => {
      const title = String(formData.get("title"));
      if (!title.trim()) return prev;

      await createTask({
        title,
        description: String(formData.get("description")),
        status: "todo",
        priority: "medium",
      });

      startTransition(() => setIsOpen(false));
      return { key: prev.key + 1 };
    },
    { key: 0 },
  );

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <form action={formAction}>
        <div key={key}>
          <input name="title" placeholder="Task title..." required />
          <input name="description" placeholder="Describe the task..." />
        </div>
        <button type="submit" disabled={isPending}>
          {isPending ? "Creating..." : "Create Task"}
        </button>
      </form>
    </Dialog>
  );
}

The key value returned from the action state is a small trick worth stealing for your own forms: incrementing it on success remounts the <div key={key}> wrapping the fields, which resets every uncontrolled input inside it in one move, without manually clearing each field by ref or by controlled state.

The genuinely non-obvious part — the thing I'd bet most teams get wrong the first time they build this — is why setIsOpen(false) needs to be wrapped in startTransition at all, given that it's already running inside the async action useActionState manages. The reason is that React doesn't currently treat a state update issued after an await as automatically part of the enclosing transition (this is a documented React limitation, not a Next.js quirk — see React's own notes on "React doesn't treat my state update after await as a transition"). Without the wrap, the dialog closes on one frame and the board updates with the new task a frame later, producing a visible flicker where the dialog is already gone but the board hasn't caught up. With the wrap, React batches the close together with the board's refresh-triggered update, so they land on the same frame.

The same rule applies to any side effect that comes after an await inside one of these actions. State updates need the startTransition wrap; things that don't touch React state — analytics pings, toasts, focus changes — don't:

await createTask({/* ... */});

startTransition(() => setIsOpen(false));
toast.success("Task created");

Step 7: Let a child signal "I'm pending" to its parent

Deleting a comment is asymmetric compared to the earlier steps: the list itself, rendered by a Server Component, has no way to represent "this specific item is about to disappear." The only component that knows a deletion is in flight is the delete button itself.

"use client";

import { Trash2 } from "lucide-react";
import { deleteComment } from "@/features/task/task-actions";

export function DeleteButton({ commentId }) {
  return (
    <button
      onClick={() => deleteComment(commentId)}
      aria-label="Delete comment"
    >
      <Trash2 className="size-3" />
    </button>
  );
}

The fix reuses the data-pending CSS trick from Step 3, but this time the button itself owns and exposes that state via useOptimistic(false), wrapped in its own <form>:

"use client";

import { useOptimistic } from "react";
import { Trash2 } from "lucide-react";

export function DeleteButton({
  deleteAction,
}: {
  deleteAction: () => void | Promise<void>;
}) {
  const [isPending, setIsPending] = useOptimistic(false);

  return (
    <form
      action={async () => {
        setIsPending(true);
        await deleteAction();
      }}
    >
      <button
        type="submit"
        disabled={isPending}
        data-pending={isPending ? "" : undefined}
        aria-label="Delete comment"
      >
        <Trash2 className="size-3" />
      </button>
    </form>
  );
}
// comment-card.tsx
<div className="rounded-lg px-3 transition-all has-data-pending:opacity-30">
  {/* comment content */}
  {deleteAction && <DeleteButton deleteAction={deleteAction} />}
</div>

The card's parent doesn't need to know anything about pending state, lifted callbacks, or context providers — it just declares "fade if anything inside me sets data-pending," and the button underneath handles the rest. The Server Function itself gets bound to a specific comment ID up at the point where it's handed down, which is what keeps DeleteButton generic and reusable rather than baking a specific comment's ID into its own definition:

<CommentCard
  comment={comment}
  deleteAction={
    comment.userName === "You"
      ? deleteComment.bind(null, comment.id)
      : undefined
  }
/>

Step 8: Make repeat visits instant, not just first visits

Everything up to this point smooths a single interaction. But every one of these reads reruns, and every one of these fallbacks repaints, on every fresh navigation — even to a page you just visited thirty seconds ago. Caching the reusable reads, and prefetching them ahead of the click, closes that gap.

This step specifically uses Cache Components, the model introduced in Next.js 16 (steps 1 through 7 all work fine without it). Turning it on is one config flag:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

Flip this on an existing app and it doesn't just apply to the routes you're actively working on — it applies prerender validation across every route in your project at once. Any route reading request-time data (cookies(), headers(), searchParams) outside a <Suspense> boundary will now fail to prerender, and needs the same restructuring shown in Step 1. If you're migrating an existing app rather than starting fresh, plan for that blast radius before flipping the flag — it's not a scoped, opt-in-per-route change.

For each read, the question becomes: should this be reused across requests, and can a write invalidate exactly this piece of it?

// features/task/task-queries.ts
import { cacheLife, cacheTag } from "next/cache";
import { getTaskById } from "@/lib/db";

export async function getTask(id: string) {
  "use cache";
  cacheLife("hours");
  cacheTag("tasks", `task-${id}`);

  return getTaskById(id);
}

The per-ID tag (task-${id}) gives one specific task a precise invalidation handle, while the broader tasks tag covers the whole list. On the mutation side, refresh() — the blunt tool used everywhere up to this point — reruns dynamic work but leaves the cache alone. Once reads are cached, mutations need to call updateTag for exactly the tags they touched instead:

// features/task/task-actions.ts
"use server";

import { updateTag } from "next/cache";
import { updateTaskStatus } from "@/lib/db";

export async function updateStatus(taskId: string, newStatus: Status) {
  const updated = await updateTaskStatus(taskId, newStatus);
  if (!updated) {
    return { success: false as const, error: "Task no longer exists" };
  }

  updateTag("tasks");
  updateTag(`task-${taskId}`);
  return { success: true as const, status: newStatus };
}

Not everything needs to move to this model at once — the comment thread in this app deliberately stays dynamic, because readers expect a live discussion to reflect changes immediately, and refresh() is the right tool for genuinely dynamic reads. Caching is selective, not all-or-nothing.

The last piece is prefetching. Under Partial Prefetching (Next.js 16.3+), <Link> prefetches a route's App Shell by default — but that shell deliberately excludes anything tied to params or searchParams, since those are specific to the link being hovered, not shared across all links to similar routes. If the destination has meaningful per-link work, opt that specific link into resolving it ahead of the click:

<Link href={`/task/${id}`} prefetch={true}>
  {/* ... */}
</Link>

Because the underlying read is now cached and tag-invalidatable, a prefetch that ran a minute ago is still valid data, not a stale snapshot you're stuck serving. In the reference implementation, task cards prefetch their detail page as they scroll into view, so by the time someone actually clicks, the page has effectively already loaded.

Picking the right primitive: a quick reference

SituationReach for
Slow data shouldn't block the whole page<Suspense>
A value needs to change while async work is still runninguseOptimistic
Async work needs pending state, error handling, or coordinated updatesuseTransition
A form needs pending, reset, and result state togetheruseActionState
An ancestor needs to react to pending state happening in a descendantdata-pending attribute + CSS
Reusable reads need to survive across requests and stay fresh after writes'use cache' + cacheTag, invalidated by updateTag / revalidateTag
Navigating between pages of the app should feel instant<Link> prefetching, prefetch={true} for URL-specific content

Common mistakes worth flagging up front

Calling a Server Function straight from an onClick and expecting the UI to update. It won't, until the next server render arrives. If you want the UI to reflect a mutation before the round-trip completes, you need useOptimistic — there's no way around introducing it.

Using useState to clear a form field inside a transition action. The setter gets deferred until the transition resolves, so the field will visibly hang around filled-in. Use a ref and direct DOM manipulation (formRef.current?.reset()) instead for anything that needs to clear on the same frame the action starts.

Forgetting that state updates after an await aren't automatically part of the transition. This is the single most common source of "why did my dialog close a frame before the list updated" bugs. Wrap the post-await update in startTransition explicitly.

Reaching for useTransition's isPending when a more localized useOptimistic(false) would do. isPending is global to that specific transition call — if multiple things share it, you can accidentally trigger UI feedback (like a board-wide fade) in places you didn't intend, as happened with the drag-and-drop step above.

Flipping on cacheComponents: true in a large existing app without auditing for request-data reads outside Suspense first. It's a project-wide behavior change, not a per-route opt-in, and an app with a lot of legacy cookies()/searchParams usage scattered through Server Components will surface a wall of prerender errors the moment you enable it.

Key takeaways

Interactive Next.js apps aren't built from one magic hook — they're built from picking the narrowest tool for each specific kind of waiting. Suspense handles the "this is slow, don't block on it" case. useOptimistic handles "show the eventual result now." useTransition and useActionState handle coordinating that work with pending states and error boundaries. And once the interactions themselves feel instant, caching and prefetching make sure repeat visits feel that way too, not just the first one.

None of these patterns are exotic — they're all shipped, documented React and Next.js APIs. What actually takes the work is learning to recognize, interaction by interaction, which one you're looking at.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper