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
| Features | Recruitment Tools | Reason |
|---|---|---|
| Initial data acquisition | RSC | Maximizing SEO and initial display speed |
| Data Updates | Server Actions | Simplicity and Type Safety of DB Operations |
| State management/UI update | React Query | Optimistic 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.