Skip to content
Index
Book a call

Lightning-Fast Table Updates in Next.js 16: The React Query Optimistic Update Pattern

Learn how to implement optimistic updates in Next.js 16 using React Query to provide a seamless user experience.

Published
Reading
11 min read

If you’ve built a data table in Next.js 16 with Server Components, you’ve probably hit this frustrating wall: deletions and updates take forever because you’re refetching from the server every single time.

I was there too. Every time a user deleted a row, they’d sit there watching a spinner for 2-3 seconds while the entire page re-fetched data from the database. It felt sluggish and outdated, especially compared to modern apps that update instantly.

Here’s how I solved it—and how you can make your Next.js tables feel lightning-fast with React Query’s optimistic updates.

The Problem: Server Components vs. Client Interactivity

Next.js 16 encourages Server Components for data fetching. It’s great for initial page loads, but here’s where it breaks down:

TypeScript
// app/users/page.tsx - Server Component
export default async function UsersPage() {
  const users = await fetchUsers(); // Fetched on server
  
  return <UsersTable data={users} />;
}

When you delete a row in your client component, you have two bad options:

  1. Use router.refresh() - Refetches the entire server component (slow!)
  2. Use Server Actions with revalidatePath() - Still requires a server round-trip

Both approaches feel sluggish because the UI waits for the server to respond before updating.

The Solution: Server Components + React Query

The trick is to use Server Components for the initial data fetch, then hand off all subsequent operations to React Query on the client side. This gives you:

  • Instant UI updates (optimistic rendering)
  • Automatic background revalidation
  • Smart caching and request deduplication
  • Built-in error handling and rollback

Let’s build it step by step.

Step 1: Set Up React Query Provider

First, install React Query:

bash
npm install @tanstack/react-query

Create a query client utility. This pattern avoids issues with React Suspense and ensures proper client/server handling:

TypeScript
// lib/get-query-client.ts
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from '@tanstack/react-query';

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000, // Data stays fresh for 1 minute
      },
      dehydrate: {
        // Include pending queries in dehydration
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined = undefined;

export function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: reuse client if it exists to prevent re-creation during Suspense
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

Now create a simple providers file:

TypeScript
// app/providers.tsx
'use client';

import { QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { getQueryClient } from '@/lib/get-query-client';

export function Providers({ children }: { children: ReactNode }) {
  const queryClient = getQueryClient();

  return (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
  );
}

Wrap your root layout with the provider:

TypeScript
// app/layout.tsx
import { Providers } from './providers';
import './globals.css';

export default function RootLayout({ children }: { children: ReactNode; }) {
return (
  <html lang="en">
    <body>
      <Providers>{children}</Providers>
    </body>
  </html>
);
}

Why this pattern? TanStack Query v5 recommends avoiding useState for QueryClient initialization when using Next.js App Router. The getQueryClient() utility handles server/browser differences and prevents React from throwing away the client during Suspense.

Step 2: Create Your API Functions

Define your API calls in a separate file. This keeps your code clean and reusable:

TypeScript
// lib/api.ts
export interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.example.com';

export async function getUsers(): Promise<User[]> {
  const res = await fetch(API_BASE + '/users', {
    cache: 'no-store', // Important for Server Components
  });
    
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json();
}

export async function deleteUser(id: string): Promise<void> {
  const res = await fetch(API_BASE + '/users/' + id, {
      method: 'DELETE',
  });
  
  if (!res.ok) throw new Error('Failed to delete user');
}

export async function updateUser( id: string, data: Partial<User> ): Promise<User> {
  const res = await fetch(API_BASE + '/users/' + id, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
  });

  if (!res.ok) throw new Error('Failed to update user');
  return res.json();
}

Pro tip: Use environment variables for your API URLs so you can easily switch between development and production.

Step 3: Prefetch Data in Server Component

This is where Next.js 16 and TanStack Query v5 shine together. Use prefetchQuery with HydrationBoundary to prefetch data on the server and hydrate it on the client:

TypeScript
// app/users/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/get-query-client';
import { UsersTable } from '@/components/UsersTable';
import { getUsers } from '@/lib/api';

// No async needed - we don't await the prefetch
export default function UsersPage() {
  const queryClient = getQueryClient();

  // Kick off prefetch without blocking (streaming-friendly)
  void queryClient.prefetchQuery({
      queryKey: ['users'],
      queryFn: getUsers,
  });

  return (
      <div className="container mx-auto p-8">
          <h1 className="text-3xl font-bold mb-6">Users Management</h1>
    
          {/* HydrationBoundary serializes the cache for client hydration */}
          <HydrationBoundary state={dehydrate(queryClient)}>
              <UsersTable />
          </HydrationBoundary>
      </div>
  );
}

Why this is powerful: The HydrationBoundary serializes the prefetched data and hydrates the client-side cache automatically. No prop drilling needed!

The Secret Sauce: Understanding HydrationBoundary

Let’s understand why HydrationBoundary + prefetchQuery is the recommended pattern in TanStack Query v5:

Without prefetching (❌ Not optimal):

TypeScript
const { data: users, isPending } = useQuery({
  queryKey: ['users'],
  queryFn: getUsers,
  // No prefetch - causes a flash of loading state!
});

if (isPending) return <div>Loading...</div>; // Users see this spinner

When your component mounts, React Query doesn’t have any data yet, so it starts fetching. This means:

  • Users see a loading spinner even though the server could have prefetched the data
  • You’re making an unnecessary duplicate request
  • Poor user experience with flickering content

With HydrationBoundary (✅ Optimal):

TypeScript
// Server Component: prefetch + dehydrate
void queryClient.prefetchQuery({
  queryKey: ['users'],
  queryFn: getUsers,
});

<HydrationBoundary state={dehydrate(queryClient)}>
  <UsersTable /> {/* No props needed! */}
</HydrationBoundary>

// Client Component: just useQuery
const { data: users = [] } = useQuery({
  queryKey: ['users'],
  queryFn: getUsers,
});

Now React Query immediately has data from the hydrated cache:

  • Zero loading state - Content appears instantly
  • No duplicate requests - The prefetched data is reused
  • No prop drilling - Data comes from hydrated cache, not props
  • Smooth hydration - Client picks up exactly where server left off

How the flow works:

  1. Server Component runs → void prefetchQuery kicks off data fetch without blocking
  2. Dehydration → dehydrate(queryClient) serializes cache state (including pending queries)
  3. Streaming → Next.js streams content to browser as it becomes ready
  4. HydrationBoundary → Rehydrates the cache on the client
  5. useQuery runs → Data is immediately available from hydrated cache
  6. User interacts → All mutations now use React Query’s cache

This is the bridge between server-side rendering and client-side interactivity. You get the best of both worlds:

  • First paint: Fast (server-rendered)
  • Time to interactive: Fast (no refetch needed)
  • Subsequent updates: Instant (optimistic updates)

Step 4: Create Reusable Mutation Hooks

To keep your components clean, let’s extract all React Query mutations into custom hooks:

TypeScript
// lib/hooks/useUserMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteUser, updateUser, type User } from '@/lib/api';

export function useDeleteUser() {
const queryClient = useQueryClient();

return useMutation({
  mutationFn: deleteUser,
  
  // This runs BEFORE the API call
  onMutate: async (deletedId) => {
    // Cancel any outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['users'] });

    // Snapshot the previous state
    const previousUsers = queryClient.getQueryData<User[]>(['users']);

    // Optimistically remove the user from UI
    queryClient.setQueryData<User[]>(['users'], (old) =>
      old?.filter((user) => user.id !== deletedId) ?? []
    );

    // Return context with previous state for rollback
    return { previousUsers };
  },
  
  // If the API call fails, rollback
  onError: (err, deletedId, context) => {
    if (context?.previousUsers) {
      queryClient.setQueryData(['users'], context.previousUsers);
    }
    console.error('Failed to delete user:', err);
  },
  
  // After success or failure, sync with server
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
});
}

export function useUpdateUser() {
const queryClient = useQueryClient();

return useMutation({
  mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>
    updateUser(id, data),
  
  onMutate: async ({ id, data }) => {
    await queryClient.cancelQueries({ queryKey: ['users'] });
    const previousUsers = queryClient.getQueryData<User[]>(['users']);

    // Optimistically update the user in UI
    queryClient.setQueryData<User[]>(['users'], (old) =>
      old?.map((user) =>
        user.id === id ? { ...user, ...data } : user
      ) ?? []
    );

    return { previousUsers };
  },
  
  onError: (err, variables, context) => {
    if (context?.previousUsers) {
      queryClient.setQueryData(['users'], context.previousUsers);
    }
    console.error('Failed to update user:', err);
  },
  
   onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['users'] });
   },
 });
}

Why extract mutations into hooks?

  • Reusability: Use the same mutation logic across multiple components
  • Testability: Easy to test mutations in isolation
  • Maintainability: Update mutation logic in one place
  • Clean components: Your UI components focus on rendering, not data logic
  • Consistency: All mutations follow the same pattern

Step 5: Build the Clean Client Component

Now your component is much simpler and focused purely on UI. Thanks to HydrationBoundary, no props are needed - the data comes from the hydrated cache:

TypeScript
// components/UsersTable.tsx
'use client';

import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getUsers, type User } from '@/lib/api';
import { useDeleteUser, useUpdateUser } from '@/lib/hooks/useUserMutations';

export function UsersTable() {
const [editingId, setEditingId] = useState<string | null>(null);

// Data is immediately available from hydrated cache - no props needed!
const { data: users = [] } = useQuery({
  queryKey: ['users'],
  queryFn: getUsers,
});

// Import and use mutation hooks - all logic is in the hooks!
const deleteMutation = useDeleteUser();
const updateMutation = useUpdateUser();

const handleDelete = (id: string) => {
  if (confirm('Are you sure you want to delete this user?')) {
    deleteMutation.mutate(id, {
      onError: () => {
        alert('Failed to delete user. Please try again.');
      },
    });
  }
};

const handleUpdate = (id: string, name: string, email: string) => {
  updateMutation.mutate(
    { id, data: { name, email } },
    {
      onSuccess: () => {
        setEditingId(null);
      },
      onError: () => {
        alert('Failed to update user. Please try again.');
      },
    }
  );
};

return (
  <div className="bg-white rounded-lg shadow overflow-hidden">
    <table className="min-w-full divide-y divide-gray-200">
      <thead className="bg-gray-50">
        <tr>
          <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
            Name
          </th>
          <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
            Email
          </th>
          <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
            Role
          </th>
          <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
            Actions
          </th>
        </tr>
      </thead>
      <tbody className="bg-white divide-y divide-gray-200">
        {users.map((user) => (
          <tr key={user.id} className="hover:bg-gray-50 transition-colors">
            <td className="px-6 py-4 whitespace-nowrap">
              {editingId === user.id ? (
                <input
                  type="text"
                  defaultValue={user.name}
                  id={`name-${user.id}`}
                  className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
                />
              ) : (
                <div className="text-sm font-medium text-gray-900">
                  {user.name}
                </div>
              )}
            </td>
            <td className="px-6 py-4 whitespace-nowrap">
              {editingId === user.id ? (
                <input
                  type="email"
                  defaultValue={user.email}
                  id={`email-${user.id}`}
                  className="border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
                />
              ) : (
                <div className="text-sm text-gray-500">{user.email}</div>
              )}
            </td>
            <td className="px-6 py-4 whitespace-nowrap">
              <span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
                {user.role}
              </span>
            </td>
            <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
              <div className="flex gap-2">
                {editingId === user.id ? (
                  <>
                    <button
                      onClick={() => {
                        const nameInput = document.getElementById(`name-${user.id}`) as HTMLInputElement;
                        const emailInput = document.getElementById(`email-${user.id}`) as HTMLInputElement;
                        handleUpdate(user.id, nameInput.value, emailInput.value);
                      }}
                      className="px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
                    >
                      Save
                    </button>
                    <button
                      onClick={() => setEditingId(null)}
                      className="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
                    >
                      Cancel
                    </button>
                  </>
                ) : (
                  <>
                    <button
                      onClick={() => setEditingId(user.id)}
                      className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
                    >
                      Edit
                    </button>
                    <button
                      onClick={() => handleDelete(user.id)}
                      disabled={deleteMutation.isPending}
                      className="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
                    >
                      {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
                    </button>
                  </>
                )}
              </div>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  </div>
);
}

Understanding the Magic: How Optimistic Updates Work

Let’s break down what happens when a user clicks “Delete” with our custom hook:

  1. User clicks “Delete” → deleteMutation.mutate(id) is called
  2. onMutate in useDeleteUser runs immediately → The row disappears from the UI instantly (before any API call)
  3. API call happens in the background → The actual deletion request is sent to your server
  4. If it fails, onError rolls back → The row reappears (React Query restores the snapshot)
  5. Component’s onError callback → Shows user-friendly error message
  6. onSettled syncs with server → Background refetch ensures data is consistent

This gives you the instant feedback of a client-side app with the reliability of server verification.

Project Structure Overview

Here’s how your files should be organized:

Text
your-app/
├── app/
│   ├── layout.tsx              # Root layout with Providers
│   ├── providers.tsx           # React Query Provider
│   └── users/
│       └── page.tsx            # Server Component (prefetch + HydrationBoundary)
├── components/
│   └── UsersTable.tsx          # Client Component (UI only)
└── lib/
  ├── api.ts                  # API functions
  ├── get-query-client.ts     # Query client utility
  └── hooks/
      └── useUserMutations.ts # Custom mutation hooks

Benefits of this structure:

  • Clear separation of concerns: Server logic, API calls, mutations, and UI are all separate
  • Easy to scale: Add new mutations without cluttering components
  • Simple testing: Each piece can be tested independently
  • Better IntelliSense: TypeScript knows exactly what each part does

Bonus: Loading and Error States

Want to show loading indicators? React Query makes it trivial. In v5, use isPending for the loading state:

TypeScript
const { data: users, isPending, error } = useQuery({
  queryKey: ['users'],
  queryFn: getUsers,
});

if (isPending) return <div>Loading users...</div>;
if (error) return <div>Error: {error.message}</div>;

When to Use This Pattern

This approach is perfect for:

  • Admin dashboards with data tables
  • CRUD applications with frequent updates
  • Any UI where instant feedback matters
  • Apps with slow or unreliable API connections

You might not need it for:

  • Simple read-only pages
  • Forms that navigate away after submission
  • Apps where server-side validation is critical before showing changes

Wrapping Up

The combination of Next.js 16 Server Components for initial data fetching and React Query for client-side mutations gives you the best of both worlds:

  • Fast initial page loads (server-rendered)
  • Instant UI updates (optimistic rendering)
  • Bulletproof error handling (automatic rollback)
  • Smart caching (fewer API calls)

Try it in your next Next.js project. Your users will thank you for the snappy experience!

Bagikan artikel

XLinkedInFacebookWhatsApp

Diskusi

Tambahkan komen

Komentar (0)

Memuat komentar...