Pattern for Auto-Refreshing a Grid After Form Submission with Next.js Server Actions and TanStack Query
This article is aimed at frontend developers who are already using or considering adopting Next.js App Router and TanStack Query. It will be especially helpful if you are building admin pages, CRUD grids, or screens where data consistency matters.
You've probably experienced this at least once: you submit a form to add an item, but the grid (table) still shows the old data. The data appears after a page refresh, but you find yourself piling up manual cache manipulation code trying to figure out how to sync automatically without refreshing.
I also initially spent way too much time handling edge cases when trying to implement optimistic updates with setQueryData. Honestly, when I first encountered this pattern, I thought "can it really be this simple?" — but once you actually use it, you experience firsthand that you can maintain data consistency without manual cache manipulation.
In this article, we'll walk step by step through a structure that automatically syncs grid data with server state without directly manipulating the cache — simply by connecting a Server Action as the mutationFn of useMutation and calling invalidateQueries in onSuccess. We'll cover the basic pattern, the global auto-invalidation pattern that has seen growing community adoption since TkDodo proposed it in May 2024, and the dual-cache problem you frequently encounter in production.
Core Concepts
What Are Server Actions?
In Next.js App Router, when you add the 'use server' directive at the top of a file or inside a function, that function becomes an async function that runs only on the server. You can call it directly from a client component like a regular function, but the actual execution happens on the server. This is extremely useful in production because it lets you handle DB operations, file processing, and sensitive logic without exposing them to the client.
// app/items/actions.ts
// @/lib/db points to lib/db.ts at the project root — a Prisma or Drizzle client
'use server';
import { db } from '@/lib/db';
export async function createItem(formData: FormData) {
const name = String(formData.get('name') ?? '');
if (!name) throw new Error('이름을 입력해주세요');
await db.item.create({ data: { name } });
}
export async function getItems() {
return db.item.findMany({ orderBy: { createdAt: 'desc' } });
}One thing to be careful about: the return value of a Server Action must be serializable. Returning a Date object as-is can cause serialization errors on the client. If you need createdAt, it is recommended to convert it to .toISOString() format or use Prisma's serialize utility.
What invalidateQueries Does
TanStack Query is a library that manages the client-side cache. When you fetch data with useQuery, it is stored in the cache keyed by a query key, and subsequent lookups with the same key return the cached value without a network request.
invalidateQueries is a function that marks this cache as "stale." It doesn't merely mark it — if the query is currently being used by a mounted component, it immediately triggers a background refetch. As a result, the user automatically receives the latest data from the server.
Fuzzy Matching: Calling
invalidateQueries({ queryKey: ['items'] })invalidates not only['items']but also all sub-queries that start with that key, such as['items', { page: 1 }]and['items', 'detail', 123]. This is particularly useful behavior for grids with pagination or filtering.
Where the Two Meet
The heart of this pattern is passing a Server Action as the mutationFn of useMutation and calling invalidateQueries in the success callback. The flow of form submission → Server Action execution (server DB update) → client cache invalidation → grid auto-refresh connects naturally.
Practical Application
Example 1 — Basic Pattern: useMutation + onSuccess (Difficulty: Basic)
This is the most common form. Fetch data with useQuery and render it in the grid, handle form submission with useMutation, and invalidate the cache on success. If your existing codebase has only 1–2 mutations, this is a good place to start.
// app/items/ItemsGrid.tsx
'use client';
import { useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getItems, createItem } from './actions';
export function ItemsGrid() {
const formRef = useRef<HTMLFormElement>(null);
const queryClient = useQueryClient();
const { data: items, isLoading } = useQuery({
queryKey: ['items'],
queryFn: () => getItems(), // Errors propagate via throw — TanStack Query handles them as error state
});
const { mutate, isPending } = useMutation({
mutationFn: createItem,
onSuccess: () => {
// After confirming Server Action success, invalidate cache → grid auto-refreshes
queryClient.invalidateQueries({ queryKey: ['items'] });
// Reset the form after success so input values are preserved on failure
formRef.current?.reset();
},
onError: (error) => {
console.error('아이템 생성 실패:', error);
},
});
if (isLoading) return <p>로딩 중...</p>;
return (
<div>
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutate(formData);
}}
>
<input name="name" placeholder="아이템 이름" required />
<button type="submit" disabled={isPending}>
{isPending ? '추가 중...' : '추가'}
</button>
</form>
<table>
<thead>
<tr><th>이름</th><th>생성일</th></tr>
</thead>
<tbody>
{items?.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{new Date(item.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}A brief summary of what each part does:
| Code Point | Description |
|---|---|
queryFn: () => getItems() |
Uses a Server Action as the query function. Thrown errors are handled as error state by TanStack Query |
mutationFn: createItem |
Passes a Server Action as the mutation function |
invalidateQueries in onSuccess |
Invalidates the entire ['items'] cache on mutation success |
formRef.current?.reset() in onSuccess |
Resets the form only after a successful server response — preserves input values on failure |
isPending |
Disables the button while the mutation is in progress to prevent duplicate submissions |
Example 2 — As the Project Grows: MutationCache Global Pattern (Difficulty: Advanced)
When mutations start multiplying to 5 or 10, writing invalidateQueries in the onSuccess of each useMutation becomes repetitive work. Our team also started out adding onSuccess individually, and only switched to this pattern after two mutations ended up with missing invalidation targets.
This pattern, proposed by TkDodo in May 2024, registers a global callback on MutationCache (a global mutation event bus injected when initializing QueryClient) and declares the invalidation target query keys in each mutation's meta field.
// lib/query-client.ts
// Note: queryClient is declared at module scope and referenced inside MutationCache.
// Declaring both in the same file avoids circular references.
import { QueryClient, MutationCache, matchQuery } from '@tanstack/react-query';
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
// Globally auto-invalidates query keys declared in meta.invalidates
queryClient.invalidateQueries({
predicate: (query) =>
mutation.meta?.invalidates?.some((queryKey: unknown[]) =>
matchQuery({ queryKey }, query)
) ?? false,
});
},
}),
});It's also a good idea to add a declaration merge so that the meta.invalidates type is inferred in TypeScript.
// types/tanstack-query.d.ts
import '@tanstack/react-query';
declare module '@tanstack/react-query' {
interface Register {
mutationMeta: {
invalidates?: unknown[][];
};
}
}After that, each useMutation only needs to declare meta. There's no need to attach onSuccess directly.
// Worth considering this pattern for projects with 3 or more mutations
const { mutate } = useMutation({
mutationFn: createItem,
meta: {
invalidates: [['items'], ['dashboard-stats']], // onSuccess not needed
},
});
const { mutate: updateItem } = useMutation({
mutationFn: updateItemAction,
meta: {
invalidates: [['items'], ['items', itemId]], // Declare all related queries
},
});
matchQuery: A utility function provided by TanStack Query v5 that checks whether a query filter matches a given query. Used to implement fuzzy matching in global callbacks.
Example 3 — For Full Type Safety: next-safe-action Adapter (Difficulty: Advanced)
If you want to handle Server Action input validation and TanStack Query integration simultaneously, you can use next-safe-action and its TanStack Query adapter. Defining a schema with Zod gives you full type inference for Server Action inputs and outputs.
The reason we chose useMutation(mutationOptions(...)) here instead of next-safe-action's native useAction hook is to maintain the same TanStack Query useMutation interface as in Examples 1 and 2, so you can use options like isPending, onSuccess, and onError consistently.
// lib/safe-action.ts — the @/lib/safe-action path alias points to this file
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient();// app/items/actions.ts
'use server';
import { z } from 'zod';
import { actionClient } from '@/lib/safe-action';
import { db } from '@/lib/db';
const createItemSchema = z.object({
name: z.string().min(1, '이름을 입력해주세요'),
});
export const createItemAction = actionClient
.schema(createItemSchema)
.action(async ({ parsedInput }) => {
const item = await db.item.create({ data: parsedInput });
return { item };
});// Client component
'use client';
import { useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { mutationOptions } from '@next-safe-action/adapter-tanstack-query';
import { createItemAction } from './actions';
export function AddItemForm() {
const formRef = useRef<HTMLFormElement>(null);
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation(
mutationOptions(createItemAction, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] });
formRef.current?.reset();
},
})
);
return (
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
// next-safe-action handles this in a type-safe manner via the Zod schema
mutate({ name: String(data.get('name') ?? '') });
}}
>
<input name="name" required />
<button disabled={isPending}>추가</button>
</form>
);
}Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| No manual cache manipulation | No need to construct optimistic updates directly with setQueryData |
| Server state consistency | Always fetches the latest data from the server, so the UI and DB are always in sync |
| Simplified code | A single invalidateQueries call can refresh all related queries |
| Fuzzy matching | Invalidating ['items'] automatically includes all sub-query keys as well |
| Type safety | When using the next-safe-action adapter, Server Action inputs and outputs are fully type-inferred |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Dual cache handling | The Next.js server cache and TanStack Query client cache are completely separate | If the layout includes Server Components, call revalidatePath inside the Server Action as well. For screens with Client Components only, invalidateQueries alone is sufficient |
| Additional network requests | A refetch occurs with every invalidation, so UX responsiveness may feel slower than optimistic updates | Recommended for screens where data consistency matters more than UX response speed (admin grids, inventory management, etc.) |
| Server Component not supported | Server Components have no TanStack Query observer, so they are not refreshed by invalidateQueries |
Use router.refresh() or revalidatePath inside the Server Action in combination |
| Global callback complexity | The MutationCache pattern requires the entire team to consistently follow the meta.invalidates convention |
Document the team convention and enforce types with TypeScript declaration merging |
Optimistic Update: A technique that immediately reflects changes in the UI by updating the client cache before waiting for a server response. The UX is fast, but you must implement rollback logic yourself when the server response fails. The
invalidateQueriespattern is a trade-off that sacrifices this complexity in favor of accuracy.
The Most Common Mistakes in Production
-
Confusion when Server Component data doesn't refresh — Our team also initially thought "is this a bug where cache invalidation isn't working?" when we first encountered this.
invalidateQueriesonly refreshes queries subscribed to viauseQueryin Client Components. To refresh data rendered by Server Components, you need to callrevalidatePath('/items')inside the Server Action or userouter.refresh()in conjunction. -
Invalidation silently ignored due to
queryKeymismatch — The first time I fell into this trap was when thequeryKeyinuseQuerywas['items', filters]but I made a typo of['item']ininvalidateQueries. Since invalidation wasn't working but there was no error, it took quite a while to find the cause. Introducing a pattern of managing query keys as constants (such asqueryKeys.items.all()) can reduce such mistakes. -
Circular references arising in the MutationCache global pattern — If you declare
queryClientin a separate file and try to importqueryClientfrom another file inside theMutationCache'sonSuccesscallback, a circular reference can occur. As shown in Example 2, it is safest to keep thequeryClientandMutationCachedefinitions in the same file.
Closing Thoughts
Simply by connecting a Server Action as the mutationFn of useMutation and calling invalidateQueries in onSuccess, you can automatically sync grid data with server state without directly manipulating the cache. When data consistency matters more than UX response speed — for example, in admin screens or CRUD grids for inventory and orders — this pattern can be the most pragmatic choice.
Three steps you can take right now:
- Pick the simplest mutation in your existing codebase and try applying this pattern — one function with
'use server', connecting it touseMutation, and callinginvalidateQueriesinonSuccessis enough. - Adding TanStack Query DevTools (
@tanstack/react-query-devtools) lets you visually confirm the process of cache invalidation and refetching, which is a great help for understanding how it works. - As mutations grow in your project, you can gradually migrate to the
MutationCacheglobal callback pattern and declare query keys inmeta.invalidates.
The greatest appeal of this pattern is that you can maintain data consistency without the rollback logic of optimistic updates or the complex update functions of setQueryData. If you find yourself thinking "can it really be this simple?" — you're probably at the same point I was when I first encountered this pattern.
References
- Automatic Query Invalidation after Mutations | TkDodo's blog
- Invalidations from Mutations | TanStack Query v5 Official Docs
- Query Invalidation | TanStack Query v5 Official Docs
- MutationCache | TanStack Query v5 Reference
- TanStack Query Integration | next-safe-action Official Docs
- Getting Started: Mutating Data | Next.js Official Docs
- Mastering Mutations in React Query | TkDodo's blog
- Server Action with TanStack Query in Next.JS Explained | Reetesh Kumar