| name | state-tanstack |
| description | State management patterns using Tanstack Query for server state and Zustand for client state. This skill should be used when setting up data fetching, implementing mutations, managing UI state, or organizing stores in React applications. |
State Management Skill
Patterns for managing state in React applications using Tanstack Query (server state) and Zustand (client/UI state).
When to Use This Skill
- Setting up data fetching in a new project
- Implementing mutations with optimistic updates
- Managing UI state (modals, filters, preferences)
- Organizing Zustand stores
- Integrating Tanstack Query with Zustand
Core Principle: Separation of Concerns
| Library | Purpose | Examples |
|---|
| Tanstack Query | Server state | API data, cached responses, background refetching |
| Zustand | Client state | UI state, drafts, local preferences, temporary data |
Golden Rule: Don't duplicate server data in Zustand. Let Query be the source of truth for anything from the server.
Setup
Installation
npm install @tanstack/react-query zustand
Query Provider
Create src/providers/query-provider.tsx:
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Add to src/app/layout.tsx:
import { QueryProvider } from '@/providers/query-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}
Tanstack Query Patterns
Query Keys Convention
Use consistent, hierarchical keys:
export const queryKeys = {
all: ['projects'] as const,
lists: () => [...queryKeys.all, 'list'] as const,
list: (filters: ProjectFilters) => [...queryKeys.lists(), filters] as const,
details: () => [...queryKeys.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.details(), id] as const,
};
Basic Query Hook
Create src/hooks/use-projects.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
interface Project {
id: string;
name: string;
status: 'active' | 'archived';
}
export function useProjects(filters?: ProjectFilters) {
return useQuery({
queryKey: queryKeys.list(filters ?? {}),
queryFn: async () => {
const params = new URLSearchParams(filters as Record<string, string>);
const res = await fetch(`/api/projects?${params}`);
if (!res.ok) throw new Error('Failed to fetch projects');
return res.json() as Promise<Project[]>;
},
});
}
export function useProject(id: string) {
return useQuery({
queryKey: queryKeys.detail(id),
queryFn: async () => {
const res = await fetch(`/api/projects/${id}`);
if (!res.ok) throw new Error('Failed to fetch project');
return res.json() as Promise<Project>;
},
enabled: !!id,
});
}
Mutations
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateProjectInput) => {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to create project');
return res.json() as Promise<Project>;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.lists() });
},
});
}
export function useUpdateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: UpdateProjectInput) => {
const res = await fetch(`/api/projects/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to update project');
return res.json() as Promise<Project>;
},
onSuccess: (data) => {
queryClient.setQueryData(queryKeys.detail(data.id), data);
queryClient.invalidateQueries({ queryKey: queryKeys.lists() });
},
});
}
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete project');
},
onSuccess: (_, id) => {
queryClient.removeQueries({ queryKey: queryKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.lists() });
},
});
}
Optimistic Updates
export function useToggleProjectStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, status }: { id: string; status: 'active' | 'archived' }) => {
const res = await fetch(`/api/projects/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!res.ok) throw new Error('Failed to update status');
return res.json() as Promise<Project>;
},
onMutate: async ({ id, status }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.detail(id) });
const previousProject = queryClient.getQueryData<Project>(queryKeys.detail(id));
if (previousProject) {
queryClient.setQueryData(queryKeys.detail(id), {
...previousProject,
status,
});
}
return { previousProject };
},
onError: (err, { id }, context) => {
if (context?.previousProject) {
queryClient.setQueryData(queryKeys.detail(id), context.previousProject);
}
},
onSettled: (_, __, { id }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.detail(id) });
},
});
}
Zustand Patterns
Store Structure
Create src/stores/ui-store.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
sidebarOpen: boolean;
toggleSidebar: () => void;
activeModal: string | null;
modalData: unknown;
openModal: (modal: string, data?: unknown) => void;
closeModal: () => void;
}
export const useUIStore = create<UIState>()((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
activeModal: null,
modalData: null,
openModal: (modal, data) => set({ activeModal: modal, modalData: data }),
closeModal: () => set({ activeModal: null, modalData: null }),
}));
Persisted Store (Preferences)
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface PreferencesState {
theme: 'light' | 'dark' | 'system';
setTheme: (theme: 'light' | 'dark' | 'system') => void;
density: 'compact' | 'normal' | 'comfortable';
setDensity: (density: 'compact' | 'normal' | 'comfortable') => void;
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
theme: 'system',
setTheme: (theme) => set({ theme }),
density: 'normal',
setDensity: (density) => set({ density }),
}),
{
name: 'preferences',
storage: createJSONStorage(() => localStorage),
}
)
);
Draft/Edit State Pattern
For editing forms without polluting server state:
interface ProjectDraftState {
draftFields: Partial<Project>;
setField: <K extends keyof Project>(key: K, value: Project[K]) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useProjectDraftStore = create<ProjectDraftState>()((set, get) => ({
draftFields: {},
setField: (key, value) =>
set((state) => ({
draftFields: { ...state.draftFields, [key]: value },
})),
clearDraft: () => set({ draftFields: {} }),
hasDraft: () => Object.keys(get().draftFields).length > 0,
}));
Usage with Query:
function ProjectEditor({ projectId }: { projectId: string }) {
const { data: project, isLoading } = useProject(projectId);
const draftFields = useProjectDraftStore((s) => s.draftFields);
const setField = useProjectDraftStore((s) => s.setField);
const clearDraft = useProjectDraftStore((s) => s.clearDraft);
const updateProject = useUpdateProject();
if (isLoading || !project) return <Skeleton />;
const merged = { ...project, ...draftFields };
const handleSave = async () => {
await updateProject.mutateAsync({
id: projectId,
...draftFields,
});
clearDraft();
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
<input
value={merged.name}
onChange={(e) => setField('name', e.target.value)}
/>
<button type="submit" disabled={updateProject.isPending}>
Save
</button>
</form>
);
}
Selector Pattern (Prevent Re-renders)
const { sidebarOpen, toggleSidebar } = useUIStore();
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
import { shallow } from 'zustand/shallow';
const { theme, density } = usePreferencesStore(
(s) => ({ theme: s.theme, density: s.density }),
shallow
);
Sliced Stores
For larger apps, split stores by domain:
src/stores/
โโโ ui-store.ts # UI state (modals, sidebar)
โโโ preferences.ts # User preferences (persisted)
โโโ project-draft.ts # Project editing draft
โโโ index.ts # Re-exports
Integration Patterns
Filter State in URL + Zustand
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { useCallback } from 'react';
export function useFilters() {
const searchParams = useSearchParams();
const router = useRouter();
const filters = {
status: searchParams.get('status') || 'all',
search: searchParams.get('search') || '',
};
const setFilter = useCallback((key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`?${params.toString()}`);
}, [searchParams, router]);
return { filters, setFilter };
}
Then pass to Query:
function ProjectList() {
const { filters } = useFilters();
const { data: projects, isLoading } = useProjects(filters);
}
Loading States
function ProjectCard({ projectId }: { projectId: string }) {
const { data: project, isLoading, isError, error } = useProject(projectId);
if (isLoading) {
return <ProjectCardSkeleton />;
}
if (isError) {
return <ErrorCard message={error.message} />;
}
return (
<Card>
<h3>{project.name}</h3>
{/* ... */}
</Card>
);
}
Best Practices
Do
- Keep server data in Tanstack Query only
- Use Zustand for UI state and drafts
- Use selectors to prevent re-renders
- Invalidate queries after mutations
- Use query key factories for consistency
Don't
- Don't store fetched API data in Zustand
- Don't create one giant store
- Don't destructure entire store (use selectors)
- Don't skip invalidation after mutations
- Don't use strings for query keys
Directory Structure
src/
โโโ hooks/
โ โโโ use-projects.ts # Query hooks for projects
โ โโโ use-users.ts # Query hooks for users
โ โโโ use-filters.ts # Filter state hooks
โโโ stores/
โ โโโ ui-store.ts # UI state
โ โโโ preferences.ts # Persisted preferences
โ โโโ index.ts # Re-exports
โโโ lib/
โ โโโ query-keys.ts # Query key factories
โโโ providers/
โโโ query-provider.tsx # Query client provider