skills/state-tanstack/SKILL.md
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.
npx skillsauth add aussiegingersnap/cursor-skills state-tanstackInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Patterns for managing state in React applications using Tanstack Query (server state) and Zustand (client/UI state).
| 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.
npm install @tanstack/react-query zustand
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, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
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>
);
}
Use consistent, hierarchical keys:
// Key factory pattern
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,
};
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';
}
// Fetch all projects
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[]>;
},
});
}
// Fetch single 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, // Don't fetch if no ID
});
}
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: () => {
// Invalidate list queries to refetch
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) => {
// Update specific project in cache
queryClient.setQueryData(queryKeys.detail(data.id), data);
// Invalidate lists
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) => {
// Remove from cache
queryClient.removeQueries({ queryKey: queryKeys.detail(id) });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: queryKeys.lists() });
},
});
}
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>;
},
// Optimistic update
onMutate: async ({ id, status }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.detail(id) });
// Snapshot previous value
const previousProject = queryClient.getQueryData<Project>(queryKeys.detail(id));
// Optimistically update
if (previousProject) {
queryClient.setQueryData(queryKeys.detail(id), {
...previousProject,
status,
});
}
return { previousProject };
},
// Rollback on error
onError: (err, { id }, context) => {
if (context?.previousProject) {
queryClient.setQueryData(queryKeys.detail(id), context.previousProject);
}
},
// Refetch after success or error
onSettled: (_, __, { id }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.detail(id) });
},
});
}
Create src/stores/ui-store.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
// Sidebar
sidebarOpen: boolean;
toggleSidebar: () => void;
// Modal
activeModal: string | null;
modalData: unknown;
openModal: (modal: string, data?: unknown) => void;
closeModal: () => void;
}
export const useUIStore = create<UIState>()((set) => ({
// Sidebar
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
// Modal
activeModal: null,
modalData: null,
openModal: (modal, data) => set({ activeModal: modal, modalData: data }),
closeModal: () => set({ activeModal: null, modalData: null }),
}));
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),
}
)
);
For editing forms without polluting server state:
interface ProjectDraftState {
// Only store the changed fields
draftFields: Partial<Project>;
// Actions
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 />;
// Merge server state with draft
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>
);
}
// Bad - subscribes to entire store
const { sidebarOpen, toggleSidebar } = useUIStore();
// Good - subscribes only to what you need
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
// Or use shallow comparison for objects
import { shallow } from 'zustand/shallow';
const { theme, density } = usePreferencesStore(
(s) => ({ theme: s.theme, density: s.density }),
shallow
);
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
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { useCallback } from 'react';
// Use URL for shareable filter state
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);
// ...
}
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>
);
}
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
tools
# Versioning Skill Semantic versioning automation based on conventional commits. Automatically manages version bumps, changelogs, and git tags using `standard-version`. ## When to Use - Before releasing a new version - When preparing a deployment - To generate/update CHANGELOG.md - When the user asks about version management - Setting up versioning for a new project ## Prerequisites - Conventional commits enforced (recommended: lefthook) - Node.js project with package.json ## Setup (One-Ti
tools
Theme generation with tweakcn for shadcn/ui and Magic UI animations. Use when setting up project themes, customizing color schemes, adding dark mode, or integrating animated components.
tools
shadcn/studio component library with MCP integration, theme generation, and block patterns. This skill should be used when building UI with shadcn components, selecting dashboard layouts, or generating landing pages. Canonical source for all shadcn-based work.
development
Enforce a precise, minimal design system inspired by Linear, Notion, and Stripe. Use this skill when building dashboards, admin interfaces, or any UI that needs Jony Ive-level precision - clean, modern, minimalist with taste. Every pixel matters.