원클릭으로
tanstack-query-patterns
TanStack Query v5 server state management with queryOptions, mutations, caching, and error handling
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
TanStack Query v5 server state management with queryOptions, mutations, caching, and error handling
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Backend testing patterns — API request construction, response verification, database state checks, error handling testing, and adaptive tool detection.
Frontend testing patterns using Playwright — navigation, interaction, assertions, screenshots on failure, and common UI testing scenarios.
Test report format with QA-XXX issue IDs compatible with code-review plugin. Defines report structure, severity levels, issue format with canonical fields, and detailed results.
Test plan structure, naming conventions, edge case generation rules, and file saving conventions for QA test plans.
Enforces AppVerk Swift coding standards across all code.
Structured concurrency and thread safety patterns in modern Swift.
| name | tanstack-query-patterns |
| description | TanStack Query v5 server state management with queryOptions, mutations, caching, and error handling |
TanStack Query patterns for server state:
queryOptions helper patternqueryOptions helper to define queries — single source of truth for queryKey + queryFnonSuccess / onError on useQuery — removed in v5; use QueryCache/MutationCache callbacksretry: false and gcTime: 0 in test QueryClientuseEffect + fetch for API calls — TanStack Query for EVERY API call// src/lib/api-client.ts
import axios from 'axios';
import type { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000/api';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Auth interceptor — attach token to requests
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}
);
// Error interceptor — handle 401/403 globally
apiClient.interceptors.response.use(
(response: AxiosResponse): AxiosResponse => response,
async (error: AxiosError): Promise<never> => {
if (error.response?.status === 401) {
localStorage.removeItem('auth-token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// src/features/users/api/usersApi.ts
import type { User, CreateUserInput, UpdateUserInput } from '../types';
import { apiClient } from '@/lib/api-client';
export async function fetchUsers(): Promise<User[]> {
const response = await apiClient.get<User[]>('/users');
return response.data;
}
export async function fetchUser(id: string): Promise<User> {
const response = await apiClient.get<User>(`/users/${id}`);
return response.data;
}
export async function createUser(input: CreateUserInput): Promise<User> {
const response = await apiClient.post<User>('/users', input);
return response.data;
}
export async function updateUser(id: string, input: UpdateUserInput): Promise<User> {
const response = await apiClient.patch<User>(`/users/${id}`, input);
return response.data;
}
export async function deleteUser(id: string): Promise<void> {
await apiClient.delete(`/users/${id}`);
}
Rules:
usersApi.ts, ordersApi.ts, productsApi.tssrc/features/<name>/api/<name>Api.ts// src/features/users/api/queries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchUser, fetchUsers } from './usersApi';
export const usersQueries = {
all: () => queryOptions({
queryKey: ['users'] as const,
queryFn: fetchUsers,
}),
detail: (id: string) => queryOptions({
queryKey: ['users', id] as const,
queryFn: () => fetchUser(id),
}),
search: (query: string) => queryOptions({
queryKey: ['users', 'search', query] as const,
queryFn: () => searchUsers(query),
enabled: query.length > 0,
}),
};
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { usersQueries } from '../api/queries';
// Standard query (loading/error states)
function UserList(): React.ReactElement {
const { data: users, isLoading, error } = useQuery(usersQueries.all());
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// Suspense query (data guaranteed at render)
function UserDetail({ userId }: { userId: string }): React.ReactElement {
const { data: user } = useSuspenseQuery(usersQueries.detail(userId));
// user is never undefined — Suspense handles loading
return <div>{user.name}</div>;
}
// With TanStack Router loader
import { usersQueries } from '@/features/users/api/queries';
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
component: UserDetailPage,
});
function UserDetailPage(): React.ReactElement {
const { userId } = Route.useParams();
// Data already cached by loader
const { data: user } = useSuspenseQuery(usersQueries.detail(userId));
return <UserProfile user={user} />;
}
// src/lib/query-client.ts
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import { toast } from 'sonner';
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Only show toast for background refetch errors
if (query.state.data !== undefined) {
toast.error(`Background update failed: ${error.message}`);
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
toast.error(`Operation failed: ${error.message}`);
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
gcTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
retry: 1,
},
mutations: {
retry: false,
},
},
});
// src/test/test-utils.tsx
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // No retries in tests
gcTime: 0, // No caching in tests
},
mutations: {
retry: false,
},
},
});
}
// src/features/users/hooks/useCreateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createUser } from '../api/usersApi';
import type { CreateUserInput } from '../types';
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateUserInput) => createUser(input),
onSuccess: () => {
// Invalidate the users list to refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
function CreateUserForm(): React.ReactElement {
const createUser = useCreateUser();
function handleSubmit(data: CreateUserInput): void {
createUser.mutate(data, {
onSuccess: () => {
toast.success('User created');
},
});
}
return (
<form onSubmit={handleFormSubmit(handleSubmit)}>
{/* form fields */}
<Button disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Create User'}
</Button>
</form>
);
}
// src/features/todos/hooks/useToggleTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toggleTodo } from '../api/todosApi';
import type { Todo } from '../types';
export function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => toggleTodo(id),
onMutate: async (id: string) => {
// 1. Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 2. Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// 3. Optimistically update
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
// 4. Return snapshot for rollback
return { previousTodos };
},
onError: (_error, _id, context) => {
// Rollback on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSettled: () => {
// Always refetch after mutation to sync with server
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
Layer 1: Axios Interceptors — Global transport errors (401, 403, network)
// Already defined in api-client.ts — handles auth redirect
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// redirect to login
}
return Promise.reject(error);
}
);
Layer 2: QueryCache/MutationCache — Global toast notifications
// Already defined in query-client.ts
new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
toast.error(`Background update failed: ${error.message}`);
}
},
});
Layer 3: Error Boundaries — Catch rendering errors gracefully
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error;
resetErrorBoundary: () => void;
}): React.ReactElement {
return (
<div role="alert" className="rounded-lg border border-destructive p-4">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button variant="outline" onClick={resetErrorBoundary}>
Try again
</Button>
</div>
);
}
// Wrap sections of the app
function UserSection(): React.ReactElement {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<Spinner />}>
<UserList />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
Layer 4: Component-Level — Specific error UI per query
function UserList(): React.ReactElement {
const { data: users, error, isLoading } = useQuery(usersQueries.all());
if (isLoading) return <UserListSkeleton />;
if (error) return <Alert variant="destructive">Failed to load users: {error.message}</Alert>;
return <ul>{users?.map(renderUser)}</ul>;
}
useSuspenseQuery — Guaranteed Dataimport { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
// Component — data is NEVER undefined
function UserProfile({ userId }: { userId: string }): React.ReactElement {
const { data: user } = useSuspenseQuery(usersQueries.detail(userId));
// user is guaranteed non-null — no loading/error checks needed
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// Parent wraps with Suspense + Error Boundary
function UserPage({ userId }: { userId: string }): React.ReactElement {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={ErrorFallback}>
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['users', userId] });
// Invalidate all queries starting with 'users'
queryClient.invalidateQueries({ queryKey: ['users'] });
// Invalidate everything (rare, use sparingly)
queryClient.invalidateQueries();
// When updating a user, invalidate both user detail and user list
onSuccess: (updatedUser) => {
queryClient.invalidateQueries({ queryKey: ['users'] }); // list
queryClient.invalidateQueries({ queryKey: ['users', updatedUser.id] }); // detail
},
// Prefetch on hover (e.g., link hover)
function UserLink({ userId }: { userId: string }): React.ReactElement {
const queryClient = useQueryClient();
function handleMouseEnter(): void {
queryClient.prefetchQuery(usersQueries.detail(userId));
}
return (
<Link
to="/users/$userId"
params={{ userId }}
onMouseEnter={handleMouseEnter}
>
View User
</Link>
);
}
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
import { UserList } from './UserList';
describe('UserList', () => {
it('renders users from API', async () => {
server.use(
http.get('/api/users', () =>
HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
])
)
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
it('shows error state on API failure', async () => {
server.use(
http.get('/api/users', () =>
HttpResponse.json({ error: 'Server error' }, { status: 500 })
)
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
import { CreateUserForm } from './CreateUserForm';
describe('CreateUserForm', () => {
it('creates user and shows success', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
})
);
render(<CreateUserForm />);
await user.type(screen.getByRole('textbox', { name: /name/i }), 'Charlie');
await user.type(screen.getByRole('textbox', { name: /email/i }), 'charlie@example.com');
await user.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => {
expect(screen.getByText(/user created/i)).toBeInTheDocument();
});
});
});
// WRONG: Duplicating server state in Zustand
const useStore = create((set) => ({
users: [],
fetchUsers: async () => {
const users = await api.getUsers();
set({ users });
},
}));
// CORRECT: Let TanStack Query manage server state
const { data: users } = useQuery(usersQueries.all());
// WRONG: Manual data fetching
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers).finally(() => setLoading(false));
}, []);
}
// CORRECT: TanStack Query handles everything
function UserList() {
const { data: users, isLoading } = useQuery(usersQueries.all());
}
// WRONG: onSuccess removed in v5
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
onSuccess: (data) => doSomething(data), // Does not exist in v5
});
// CORRECT: Use useEffect or handle in QueryCache
const { data } = useQuery(usersQueries.all());
useEffect(() => {
if (data) doSomething(data);
}, [data]);
queryOptions for every query definitionfeatures/<name>/api/