aussiegingersnap

state-tanstack

0
0
# Install this skill:
npx skills add aussiegingersnap/cursor-skills --skill "state-tanstack"

Install specific skill from multi-skill repository

# 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.

# SKILL.md


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, // 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>
  );
}

Tanstack Query Patterns

Query Keys Convention

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,
};

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';
}

// 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
  });
}

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: () => {
      // 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() });
    },
  });
}

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>;
    },

    // 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) });
    },
  });
}

Zustand Patterns

Store Structure

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 }),
}));

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 {
  // 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>
  );
}

Selector Pattern (Prevent Re-renders)

// 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
);

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';

// 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);

  // ...
}

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

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.