The Day I Claimed

The Day I Claimed "React Query is Obsolete" in Next.js 14/15—And My Browser Died

When the Next.js 14 App Router was released, I was certain: "I don't need messy client-side state management anymore. Server Actions are the only answer."

The result? In production, user browsers became sluggish. Complex dashboards suffered from jittery UI, and the network tab turned a frantic red with a barrage of re-fetches. That sweat-soaked night marked the moment my design philosophy was completely overturned.

If you're currently clinging to the sweet fantasy that "Server Actions make React Query unnecessary," let me share the reason I spent three hours stuck in a swamp, and give you the definitive "separation of concerns" for modern development.


1. Why the "Server Actions Only" Approach Led to Disaster

The App Router is undoubtedly revolutionary. It eliminates the need to set up manual API routes and allows for type-safe communication with the server. However, Server Actions are a means of requesting data from the server; they are not a tool for managing client-side state.

My biggest mistake was spamming `revalidatePath` to force UI updates. With that approach, even a simple list reordering triggers a full page re-fetch, destroying the user experience (UX).

FeatureServer Actions (Standalone)Combined with React Query
Data FetchingRequires full page reload/revalidationUpdates only cached data
UI ResponseStutters while waiting for serverInstant updates via `onMutate` (Optimistic UI)
State ManagementServer-dependent (Unstable)Centralized on the client side
Best Use CaseSimple form submissionsDynamic dashboards and interactive lists

2. Defining the "Boundary" by Reintroducing React Query

Ultimately, what defines a great user experience is how data is cached and updated instantly on the client side. I have settled on the following hybrid architecture:

// Client-side: React Query governs the "experience"
'use client'

import { useMutation, useQueryClient } from '@tanstack/react-query'

function TaskItem({ task }) {
  const queryClient = useQueryClient();

  const { mutate } = useMutation({
    mutationFn: (data) => updateTask(task.id, data),
    // The "magic" of updating the UI without waiting for a server response
    onMutate: async (newData) => {
      await queryClient.cancelQueries({ queryKey: ['tasks'] });
      const previousTasks = queryClient.getQueryData(['tasks']);
      
      queryClient.setQueryData(['tasks'], (old) => 
        old.map(t => t.id === task.id ? { ...t, ...newData } : t)
      );
      return { previousTasks };
    },
    onError: (err, newData, context) => {
      // Rollback to the previous state on failure (this provides peace of mind)
      queryClient.setQueryData(['tasks'], context.previousTasks);
    }
  });

  return <button onClick={() => mutate({ status: 'DONE' })}>Complete</button>;
}

With this setup, the UI never freezes during communication. Even if the server is slow, the user sees an immediate "Success" state. This is the standard for modern web applications.


3. The "Three-Hour Penalty" of Hydration Errors

I want to share one specific trap that cost me three hours: when passing data fetched in Server Components as initial values for React Query without using `dehydrate`.

If you skip this, you enter Hydration Error Hell. The rendering results between the server and client will slightly mismatch, leading to the merciless console message: `Text content does not match server-rendered HTML`.

The solution is this:

// Fetch on the server and "carry" it over to the client
export default async function Page() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({ queryKey: ['tasks'], queryFn: fetchTasks });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TaskList />
    </HydrationBoundary>
  );
}

By serializing the server-fetched data with `dehydrate` before passing it to the client, you ensure Next.js doesn't panic about discrepancies. Skipping this step is exactly what cost me those three hours.


4. Conclusion: Our Design Rules

Don't insist on doing "everything" with Server Actions. It’s like trying to ride a bicycle on a high-speed highway. Memorize these rules:

1. Initial Load: Fetch via Server Components and pass them to the client through `HydrationBoundary`.
2. Frequent Updates: Trigger Server Actions behind the scenes of React Query's `useMutation`.
3. Simple Form Submissions: React Query isn't even necessary. `useActionState` (formerly `useFormState`) is sufficient.

Server Actions are the "bridge to the server"; React Query is the "cache management for the experience."

By respecting this boundary, your app will move with a smoothness that feels like a different product entirely. While Next.js's evolution is incredible, the expertise in caching and retries built into React Query remains one of our most powerful weapons.

Stop trying to be stubborn—make peace with React Query. It’s the fastest way forward.


🤖 Developer Blog & Content

The developer's personal blog is also available for you to check out: 👉 muttyan.com (Developer Blog)