The abyss of Next.js App Router: conquering the “uneasy coexistence” of Server Actions and React Query

The abyss of Next.js App Router: conquering the “uneasy coexistence” of Server Actions and React Query

Have you ever been using Next.js 14/15's App Router and suddenly stopped working in the middle of the night? "Should I just use Server Actions or should I introduce React Query (TanStack Query)?" If you get this line wrong, your UI will wobble, your cache will be inconsistent, and you'll end up with a graveyard of code that no one knows why it's working.

Today, I'm going to share with you a practical solution that I found after getting stuck in this "magical realm" and almost throwing out my keyboard many times.


1. Why does “Server Actions alone” fail?

If you try to build a UI using only Server Actions, tempted by sweet words such as "no need to write API Routes" or "type safety," you will always run into the following wall.

Filthy Code Fragments

For example, if you try to write ``spinner display while loading'', ``error handling'', and ``immediate UI update after success'' associated with form submission using only Server Actions, the component will become a sea of unmanageable `useState`.

// Nightmare example: state management with Server Actions alone
export default function TodoForm() {
  const [loading, setLoading] = useState(false); // State 1
  const [error, setError] = useState(null); // state 2
  const [data, setData] = useState(null); // state 3

  const handleSubmit = async (formData: FormData) => {
    setLoading(true); // Manual state management is scattered
    try {
      await addTodo(formData);
      // Re-acquisition is necessary to update the screen, but how can I control it from here?
    } catch (e) {
      setError(e);
    } finally {
      setLoading(false);
    }
  };
  // ...Below, UI conditional branch hell
}
`

This approach may be fine for small forms, but for interactive apps, it forces manual management of "loading states" and "optimistic updates" and destroys code maintainability. **


2. Best practice: strict separation of roles

The conclusion I reached was simple. Divide the roles into ``Server Actions is the backend interface'' and ``React Query is the frontend state management''.

Blueprint for role division

FeaturesRecruitment ToolsReason
Initial data acquisitionRSCMaximizing SEO and initial display speed
Data UpdatesServer ActionsSimplicity and Type Safety of DB Operations
State management/UI updateReact QueryOptimistic update/cache control

3. Practice: Wrapping Server Actions with React Query

By passing Server Actions to React Query as `mutationFn`, you can have both the ``ease of use'' of Server Actions and the ``UX control power'' of React Query.

// hooks/useTodos.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addTodo } from '@/actions/todo';

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

  return useMutation({
    mutationFn: addTodo, // Pass the Server Action as is
    onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
      // Optimistic update: Rewrite UI without waiting for response
      queryClient.setQueryData(['todos'], (old: any) => [...old, newTodo]);
    },
    onSettled: () => {
      // Ensure final data integrity
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });
}
`

[Important] Avoid double management of caches

The most important thing to note here is that you should not call `revalidatePath` inside Server Actions.

If you call `revalidatePath` in Server Action and `invalidateQueries` in React Query at the same time, the App Router's server cache and client-side cache will conflict, causing a "double re-rendering" that causes the UI to flicker momentarily.

Accepting trade-offs:
Abandoning `revalidatePath` and entrusting cache control to React Query means giving up part of ``server-driven cache updates''. This is a risk in environments where JS is disabled or where a very robust caching strategy is required. However, in order to realize a modern SPA-like experience with Next.js, this "delegation of authority to the front desk" is the most realistic option.


4. Master type safety

When using `useActionState` (formerly `useFormState`) and React Query together, type definitions should not be neglected. Explicitly specifying the return type dramatically improves the development experience.

// Type definition for actions/todo.ts
export type ActionResponse = {
  success: boolean;
  message?: string;
  data?: Todo;
};

export async function addTodo(prevState: any, formData: FormData): Promise<ActionResponse> {
  // ...process
  return { success: true, data: newTodo };
}
`

By passing this type to the `useMutation` generic, "success data" and "error handling" are completely type-completed on the component side.


Conclusion: Give the code a “feeling of being alive”

Server Actions are a powerful weapon. However, if you connect it directly to the UI, you will destroy the delicate armor that is the UX.

App Router's true power is unlocked for the first time by equipping it with the "armor" of React Query. Please remember this article when you feel like hitting the monitor all the time while debugging late at night. Clean and fast code is always waiting for you after the muddy trial and error.

Now, let's brew some coffee and continue writing.


🤖 Developer blog/public content

The developer's personal blog is also available, so please take a look. 👉 muttyan.com (Developer Blog)