State & Data
Canonical conventions for server state, client state, and data validation.
Decision Tree
Is this data that comes from an API or database? YES → TanStack Query (never Zustand) NO → Is this shared across multiple disconnected components? YES → Zustand slice NO → React useState / useReducerTanStack Query (Server State)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'import { apiGet, apiMutate } from '@/lib/api-client'import { paginatedResponseSchema } from '@/lib/api-types'import { invoiceSchema } from './types'
// List query — paginatedexport function useInvoices(page = 1) { return useQuery({ queryKey: ['invoices', page], queryFn: () => apiGet(paginatedResponseSchema(invoiceSchema), `/api/invoices?page=${page}`), placeholderData: (prev) => prev, // keep old data while fetching next page })}
// Detail queryexport function useInvoice(id: string) { return useQuery({ queryKey: ['invoices', id], queryFn: () => apiGet(invoiceSchema, `/api/invoices/${id}`), enabled: Boolean(id), })}
// Mutation with optimistic updateexport function useMarkInvoicePaid() { const queryClient = useQueryClient() return useMutation({ mutationFn: (id: string) => apiMutate(invoiceSchema, 'PATCH', `/api/invoices/${id}`, { status: 'paid' }), onSuccess: (updated) => { queryClient.setQueryData(['invoices', updated.id], updated) queryClient.invalidateQueries({ queryKey: ['invoices'] }) }, })}Rules
- Cache key convention:
[resource, ...filters]→['invoices', page],['invoices', id] - Invalidate by resource root after mutations:
queryKey: ['invoices'] - Use
placeholderData: (prev) => prevfor paginated lists to prevent flash-of-empty - Use
enabled: Boolean(id)for queries that require a resolved parameter
Zustand (Client / UI State)
import { create } from 'zustand'
interface UIState { sidebarOpen: boolean setSidebarOpen: (open: boolean) => void commandPaletteOpen: boolean setCommandPaletteOpen: (open: boolean) => void}
export const useUIStore = create<UIState>()((set) => ({ sidebarOpen: false, setSidebarOpen: (open) => set({ sidebarOpen: open }), commandPaletteOpen: false, setCommandPaletteOpen: (open) => set({ commandPaletteOpen: open }),}))Rules
- One slice per domain:
ui-store.ts,user-preferences-store.ts - Never put server data in Zustand — use TanStack Query
- Never put form state in Zustand — use React Hook Form
Zod (Validation)
import { z } from 'zod'
// Request schema — what the form submitsexport const createUserSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email(), role: z.enum(['admin', 'member', 'viewer']),})
// Response schema — what the API returnsexport const userSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), role: z.enum(['admin', 'member', 'viewer']), tenantId: z.string(), createdAt: z.string().datetime(),})
export type CreateUserInput = z.infer<typeof createUserSchema>export type User = z.infer<typeof userSchema>Rules
- Parse at boundaries: API responses (in api-client), form submissions (in onSubmit)
- Never type-cast (
as User) — let Zod validate - Co-locate schema with the feature, not in a global types file
- Export both the schema and the inferred type from the same file