Skip to content

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 / useReducer

TanStack Query (Server State)

features/invoices/api.ts
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 — paginated
export 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 query
export function useInvoice(id: string) {
return useQuery({
queryKey: ['invoices', id],
queryFn: () => apiGet(invoiceSchema, `/api/invoices/${id}`),
enabled: Boolean(id),
})
}
// Mutation with optimistic update
export 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) => prev for paginated lists to prevent flash-of-empty
  • Use enabled: Boolean(id) for queries that require a resolved parameter

Zustand (Client / UI State)

lib/stores/ui-store.ts
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)

features/users/types.ts
import { z } from 'zod'
// Request schema — what the form submits
export 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 returns
export 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