Building interactive apps
Learn how to build responsive interactions with Server Functions, transitions, optimistic UI, and pending feedback.
When a user interaction requires server-side work, the result is not available immediately. Network requests take an unknown amount of time to complete and may succeed or fail. While waiting, the client should provide feedback and respond to those states. Otherwise, users are left looking at stale data and may wonder whether anything is happening.
This guide adds responsive feedback to a task management app called Taskboard. Each step handles a different kind of pending work, from streaming slow reads to confirming mutations before the server responds.
Good to know: The patterns in this guide also improve Core Web Vitals. Streaming with <Suspense> lowers FCP and LCP by letting the shell paint while slow reads finish. Optimistic UI and transitions lower INP by keeping the click frame fast. Prefetching the next route makes FCP, LCP, and INP on the destination page near-instant. These patterns help, but they don't replace the usual INP work: shipping less client JavaScript and avoiding blocking roundtrips during interactions.
Example
The patterns build in order: stream slow data behind loading boundaries, add an instant priority toggle, build a reusable filter with a callback prop, show comments before they're confirmed, move cards between columns on drop, manage form lifecycle in a create dialog, and signal pending deletion to a parent. The final step caches the reusable reads so navigations stay instant.
The companion Taskboard demo (source) lets you see each pattern from this guide in action.
The app is organized by feature. Everything for the task domain (queries, Server Functions, and components) lives under features/task/. Shared UI primitives live in components/ui/, and pages in app/ compose feature components. The file paths throughout this guide follow that structure.
The starting point is a Server Component app that reads tasks and comments from a database, with no <Suspense> boundaries and no caching. Client Components call Server Functions to mutate that data. After each mutation, the Server Function calls refresh() so the server re-renders with fresh data:
Step 1: Stream slow data with Suspense
The task detail page reads the task and its comments. When both are awaited at the top of the page component, the whole page blocks until the slowest read resolves.
With this version, nothing renders until getTask resolves, and the comments read starts only after that.
Split the reads into separate components, pass each one a promise, and wrap them in <Suspense>. The page becomes synchronous, returns the shell immediately, and the server streams each section in as its data resolves:
Each section component awaits its own data:
Calling params.then() inline in JSX keeps the page synchronous. The outer <Suspense> covers the params resolution and the task detail read. Once TaskDetail resolves, React reveals the task header and the nested <Suspense> boundary, which shows CommentSectionSkeleton while comments load.
The page shell now paints immediately, the task header streams in when getTask resolves, and the comments section follows when getComments resolves.
For more on streaming patterns, see the Streaming guide.
Step 2: Respond instantly to a toggle
Each task card has a priority indicator that cycles through low, medium, and high on click.
The button renders the priority prop from the server. Calling a Server Function starts the mutation, but it doesn't change that prop in the current render.
Here the Server Function is called directly:
If you click the dot, the Server Function runs, but the button keeps rendering the old priority prop until the next server render arrives.
Replace the direct call with useOptimistic and useTransition. useOptimistic provides a value to render in place of the stale prop, and useTransition runs the Server Function as part of a transition. The optimistic value applies for as long as that transition is pending:
The setOptimisticPriority call updates the UI on the current frame with the next value in the cycle. Reading from optimisticPriority instead of the prop means rapid double-clicks cycle correctly rather than reading a stale closure value.
When the transition ends and fresh data arrives, the optimistic value reverts to the new server-rendered prop. If the Server Function inside useTransition throws, the error is forwarded to the nearest error boundary without a manual try/catch.
The color now changes the moment the mouse lifts. Clicking again cycles to the next value without waiting for the first mutation to finish.
Step 3: Filter with pending feedback
The board has label filter chips (Design, Frontend, Backend). Clicking a chip updates a query param to filter the task list.
The selected filter comes from the URL. Calling router.push() starts a client navigation, and the server re-renders the board with the new search params. Until that navigation completes, the page keeps rendering the old URL state.
Here router.push() is called directly:
With this version, router.push() starts the navigation, but there's no local pending state. The chip keeps rendering the old URL value, and the board gives no sign that a new server render is loading.
Separate the filter into two components. LabelFilter owns the query string update and passes the navigation callback to a reusable ChipGroup, which handles all the pending feedback:
LabelFilter decides where the app navigates and holds no pending state of its own.
Handle pending feedback internally
ChipGroup accepts a changeAction prop and calls it inside its own startTransition. It uses two useOptimistic hooks: one for the selected chip, and one for the data-pending attribute that ancestor components can style against:
The consumer passes a callback (changeAction), and the design component runs it inside a transition. By convention, props named action or suffixed with Action signal this behavior. See Exposing action props from components in the React docs for this pattern.
The data-pending attribute on the ChipGroup root element lets ancestor components react through CSS without any coordination. The board on the home page uses group-has-data-pending:opacity-50 to dim while the transition runs, without replacing the board with a skeleton. Because ChipGroup renders inside the group element, the attribute is available to any ancestor for styling.
The "Frontend" chip now highlights on the current frame. The board dims while the filtered data loads, then updates with the filtered tasks.
Step 4: Add comments with instant feedback
The task detail page has a comment section. Submitting a comment writes new data on the server, but a server component renders the persisted list, so it can't include the new comment until the next server render arrives.
Here a comment form uses controlled state for the input and calls the Server Function directly:
With this version, the Server Function runs on submit, but the input stays filled and the list doesn't change until the server responds.
Split the comment list into two parts. A server component renders the persisted comments, and a client component tracks only the pending comments using useOptimistic([]) with an empty initial array. When the transition completes and a new render arrives with fresh data, the pending list resets to empty and the real comment appears in the server-rendered list:
Three things happen on submit:
formRef.current?.reset()clears the input through direct DOM manipulation. Inside a transition,useStatesetters are deferred until the transition completes, butuseOptimisticsetters and direct DOM calls likeformRef.reset()apply on the current frame.setPendingCommentsadds the new comment to the pending list with a client-generated UUID.- The form
actionruns the handler inside a transition.
The server component renders the real list alongside it:
The input now clears on submit and a faded comment card appears at the top of the list. When the server confirms, the faded card disappears and the real comment takes its place in the server-rendered list.
Step 5: Move cards between columns
The board has three columns: Todo, In Progress, and Done. Each column renders tasks from the server-provided tasks prop.
Dropping a card starts a Server Function that writes the new status. The tasks prop doesn't change until the next server render arrives, so the client needs a temporary task list for the pending move.
Here the Server Function is called on drop:
The component receives a promise from the parent server component (see Step 1's <Suspense> boundary) and unwraps it with use. With this version, a card dragged from "Todo" to "In Progress" stays in the old column while the server processes the update, then jumps to the new column after the response arrives.
Add useOptimistic with a reducer to remap the card's status on the current frame. The reducer receives the full task list and an action describing which card moved where:
The reducer maps over the task list and updates the status of the dragged card, leaving the rest unchanged. If a background refresh arrives mid-drag, for example from polling or another user's mutation, React re-runs the reducer with the updated base data so the optimistic move sits on top of fresh data.
This step uses the standalone startTransition instead of useTransition because the hook's isPending would trigger the board fade from Step 3. The optimistic move already covers the visual feedback, so no pending indicator is needed. If the Server Function fails, the card reverts.
A card dragged from "Todo" to "In Progress" now lands in the target column the moment you release.
Step 6: Manage form lifecycle
The board has a "New Task" button that opens a create dialog. The form action creates the task on the server, then refreshes the board.
The dialog coordinates three pieces of client state while the action is pending: the submit button, the field values, and whether the dialog is open. useActionState handles all three.
Here a form manages submission state manually and calls the Server Function in an onSubmit handler:
With this version, the submit handler runs, but two separate useState calls manage the pending state. The dialog closes before the board shows the new task, because setIsOpen(false) fires outside of a transition. The form fields also keep their values across submissions unless you track a reset key yourself.
useActionState handles all three concerns: isPending for the button, key-based reset for the fields, and a wrap around the dialog close:
The key in the returned state controls form reset. On success, key increments, which remounts the <div key={key}> and resets every input inside it.
useActionState runs the action as a transition, so the dialog stays open and isPending stays true while createTask runs. State updates after the await aren't automatically part of that transition, so wrapping setIsOpen(false) in startTransition runs the close as part of the transition.
With that wrap in place, React batches the dialog close with the board update that refresh() triggers inside createTask, so the new task appears at the moment the dialog closes. Without it, the dialog closes first and the board updates a frame later.
Good to know: This limitation is documented in React under React doesn't treat my state update after await as a transition. Until it's fixed, wrapping post-await state updates in startTransition is the recommended workaround.
The same post-await window is where side effects like toasts belong:
Side effects that don't affect rendered state, such as analytics, toasts, and focus changes, run after the await resolves. They don't need a transition because they don't update React state.
The button text now changes to "Creating..." and dims on submit. When the server responds, the fields clear, the dialog closes, the new task appears on the board, and a toast confirms the action.
Good to know: The companion app adds status, priority, assignee, and label pickers to the modal. The pattern is the same: hidden inputs track each picker's state, and key resets them all on success.
Step 7: Signal pending deletion to a parent
Each comment card has a delete button that removes the comment on the server, and the comment disappears from the list after the next render.
Between the click and that render, the card still shows the old data. The list itself can't show the pending removal, so the signal has to come from the button.
This step reuses the data-pending CSS hook from Step 3, but drives it from useOptimistic(false) instead of the isPending from useTransition.
Here the Server Function is called directly:
With this version, the comment stays fully visible until the next server render.
Wrap the action in a <form>, track pending state with useOptimistic(false), and expose it through a data-pending attribute. Accept the action as a deleteAction prop so the parent comment card can react with CSS:
The parent comment card uses Tailwind's has-data-pending: variant to fade itself when any descendant sets the data-pending attribute. The button is the only component that knows when deletion is pending, so exposing that state as a CSS attribute lets any ancestor dim itself without lifting state or threading callbacks:
The parent server component binds the Server Function to the comment ID, which keeps DeleteButton reusable:
The card now fades to 30% opacity the moment the click registers. When the server confirms and the next render arrives, the card unmounts from the list.
Step 8: Make repeat navigation instant
Every pattern so far smooths a single interaction. Across navigations, the same reads rerun and the same fallbacks paint again. Caching the reusable reads and prefetching them with the real request closes that gap without moving the data model into the browser.
This step uses Cache Components, introduced in Next.js 16. Steps 1 through 7 work without it. If you aren't using Cache Components, see Caching without Cache Components.
Enable it in next.config.ts:
Enabling Cache Components applies prerender validation across every route, not just the ones in this step. A route that reads request data such as cookies(), headers(), or searchParams outside <Suspense> now blocks prerendering and needs the Step 1 treatment. To turn it on in an existing app, see Migrating to Cache Components.
Cache reusable reads
For each server read, ask whether the next viewer would see the same thing. Tasks and comments would, so mark each one with 'use cache' and tag it for invalidation:
The per-id tag task-${id} gives a single task its own handle. The broader tasks tag covers the list. See the Caching guide for cache scoping ('use cache: private', 'use cache: remote') and tag patterns.
Invalidate from mutations
refresh() reruns dynamic work but leaves cached entries in place. Replace it with updateTag for the tags each write touched:
Every Server Function follows the same shape: run the write, then call updateTag for the cached reads that changed. See the Revalidating guide for tag patterns, time-based revalidation, and on-demand invalidation from route handlers.
Prefetch the destination with the real request
Good to know: Partial Prefetching ships in Next.js 16.3 and later with partialPrefetching: true set in next.config.ts. See the Adopting Partial Prefetching guide for the migration path, plus the Runtime Prefetching and Instant Navigation guides for the full picture.
Under Partial Prefetching, <Link> prefetches the destination's App Shell by default. Setting prefetch={true} extends the prefetch to include the destination's cached content.
If the destination's cached reads depend on request data (cookies, headers, searchParams, or params not resolved by generateStaticParams), opt the route into runtime prefetching with the prefetch segment export so those reads resolve at prefetch time:
A prefetch only pays off when the destination's reads are cached: the tags from the previous subsections give the prefetched output a stable slot in the Client Cache, so a later navigation reuses it instead of refetching. In the companion app, each task card is a <Link> to the task detail page. The same element is also draggable, and the browser distinguishes a click from a drag natively. Because the cards are links, the detail page prefetches as they scroll into view, so the destination paints instantly by the time the user clicks. See the Prefetching guide and Runtime Prefetching guide for prefetch modes, and the Instant Navigation guide for validating that navigations stay instant in production.
Next steps
The patterns in this guide combine a handful of primitives:
| Situation | Use |
|---|---|
| Slow data should stream in without blocking the page | <Suspense> |
| A value should update while async work runs | useOptimistic |
| Async work needs pending state, error handling, or coordinated UI updates | useTransition |
| A form needs pending, reset, and result state | useActionState |
| An ancestor should show pending state for work happening elsewhere | data-pending attribute styled with CSS |
| Reusable reads should survive across requests and stay fresh after writes | 'use cache' with cacheTag, revalidated by updateTag or revalidateTag |
| Navigation between pages of an interactive app should feel instant | <Link> prefetching with Partial Prefetching, and Runtime prefetching for request-dependent reads |
Most patterns mix two or more of these. Reach for whichever primitive fits the constraint you're solving.
Related guides:
- Streaming for loading boundaries and
<Suspense>patterns - Instant Navigation for validating that navigations stay instant
- View Transitions for animating state changes