React Query Patterns
Server state as single source of truth, centralised query keys, no useEffect for data fetching.
Status: Extracted
Purpose: React Query data fetching patterns, query hooks, and API client configuration.
Core Principles
React Query for All Server Data — Single Source of Truth
- Data fetching: Use React Query hooks in
clientDomains/ - Single source of truth: One logical source per domain/aggregate (e.g. messages by accountId). No duplicate server state in component state or context.
- Centralised source and subset hooks: Build dedicated query hooks for different views (e.g.
useMessages(accountId),useMessagesForPhone(phone)). Subset/derived data reads from the same query key/cache; no separate fetches that duplicate the source of truth. - Caching: Automatic background updates, deduplication
- Loading states: Built-in
isLoading,errorstates - Mutations: Optimistic updates with
useMutation
No useEffect for Server Data
- Do not use useEffect to sync or fetch server data. Use React Query (useQuery / useMutation) for all server data.
- Realtime/polling may use useEffect only for subscription setup/teardown (e.g. intervals, cleanup on unmount).
Data Fetching Patterns
API Routes (Primary Pattern)
When: All server operations (read and write)
Pattern: Define endpoints in api.ts, use httpApiClient (Axios instance)
Examples: useAccounts, useMessages, useDocumentMutations
Reference: src/clientDomains/account/api.ts
API Client Configuration
Base URL: Configured via environment variable (NEXT_PUBLIC_ACCOUNT_API_URL)
Authentication: JWT tokens in HTTP-only cookies or Authorization header
Error Handling: Centralized error handling in Axios interceptors
Request/Response Transformation: Handled in interceptors
Query Hooks Pattern
Standard Query Hook
Location: clientDomains/{domain}/use{Entity}.ts
Pattern:
export function useAccounts() {
return useQuery({
queryKey: queryKeys.accounts.all,
queryFn: () => accountApi.getAccounts(),
});
}
export function useAccount(accountId: string) {
return useQuery({
queryKey: queryKeys.accounts.byId(accountId),
queryFn: () => accountApi.getAccountById(accountId),
enabled: !!accountId,
});
}Query Hook Best Practices
- Centralized Query Keys: Use
queryKeysfromconstants/queryKeys.ts - Type Safety: Type all API responses
- Error Handling: Use React Query's built-in error states
- Loading States: Use
isLoading,isFetchingappropriately
Query Keys
Centralized Query Keys
All query keys are centralized in constants/queryKeys.ts.
Structure:
- Use objects for grouping (e.g.,
messages,accounts,documents) - Use functions for parameterized keys (e.g.,
byAccountId(accountId)) - Consistent naming:
all,byId,byAccountId, etc.
Example:
export const queryKeys = {
accounts: {
all: ['accounts'] as const,
byId: (id: string) => ['accounts', id] as const,
byPhone: (phone: string) => ['accounts', 'phone', phone] as const,
},
messages: {
all: ['messages'] as const,
byAccountId: (accountId: string) => ['messages', 'account', accountId] as const,
byId: (id: string) => ['messages', id] as const,
},
documents: {
all: ['documents'] as const,
byId: (id: string) => ['documents', id] as const,
},
};File Organization
Client Domain Structure
src/clientDomains/
├── account/
│ ├── api.ts # API endpoints
│ ├── useAccounts.ts # Query hooks
│ ├── useAccountMutations.ts
│ └── useAccountRealtime.ts
├── message/
│ ├── api.ts
│ ├── useMessages.ts
│ ├── useMessageMutations.ts
│ └── useMessageRealtime.ts
└── document/
├── api.ts
├── useDocuments.ts
└── useDocumentMutations.ts
API Client Location
src/lib/api/
└── client.ts # API client configuration
Related Documents
- Context Usage Guidelines - When to use contexts
- State Management Patterns - State management principles
- Optimistic Updates - Mutation patterns
Framework implementations
See also:
The clientDomains/ directory is the frontend data layer. Every domain gets a vertical slice containing API functions and React Query hooks. Nothing outside clientDomains/ calls APIs or manages server state.
Domain Structure
clientDomains/
└── account/
├── api.ts # Pure async API functions (no hooks)
├── useAccounts.ts # Query hooks (read)
├── useAccountMutations.ts # Mutation hooks (write)
└── types.ts # Domain-specific types (if not in /types)
api.ts — Pure API Functions
API functions are plain async functions. No hooks, no React Query. They receive parameters and return data or throw errors.
// clientDomains/account/api.ts
import { httpApiClient } from '@/lib/api/client'
import type { Account, CreateAccountRequest } from '@/types/account'
export const accountApi = {
list: async (params?: { limit?: number; offset?: number }): Promise<Account[]> => {
const { data } = await httpApiClient.get('/api/v1/accounts', { params })
return data
},
getById: async (id: string): Promise<Account> => {
const { data } = await httpApiClient.get(`/api/v1/accounts/${id}`)
return data
},
create: async (payload: CreateAccountRequest): Promise<Account> => {
const { data } = await httpApiClient.post('/api/v1/accounts', payload)
return data
},
update: async (id: string, payload: Partial<Account>): Promise<Account> => {
const { data } = await httpApiClient.patch(`/api/v1/accounts/${id}`, payload)
return data
},
delete: async (id: string): Promise<void> => {
await httpApiClient.delete(`/api/v1/accounts/${id}`)
},
}constants/queryKeys.ts — Centralised Query Keys
All cache keys are defined here. No inline strings in hooks.
// constants/queryKeys.ts
export const queryKeys = {
accounts: {
all: ['accounts'] as const,
byId: (id: string) => ['accounts', id] as const,
byEmail: (email: string) => ['accounts', 'email', email] as const,
},
messages: {
all: ['messages'] as const,
byAccountId: (accountId: string) => ['messages', 'account', accountId] as const,
byId: (id: string) => ['messages', id] as const,
},
auth: {
session: ['auth', 'session'] as const,
},
// add one entry per domain
} as constHierarchy enables partial invalidation:
// Invalidate ALL account queries
queryClient.invalidateQueries({ queryKey: queryKeys.accounts.all })
// Invalidate only one account
queryClient.invalidateQueries({ queryKey: queryKeys.accounts.byId(id) })Query Hooks — Source & Subset Pattern
Source hooks fetch the full dataset for a domain. Subset hooks derive specific views from the same cache entry — no separate fetch.
// clientDomains/account/useAccounts.ts
import { useQuery } from '@tanstack/react-query'
import { queryKeys } from '@/constants/queryKeys'
import { accountApi } from './api'
import type { Account } from '@/types/account'
// Source hook — fetches all accounts
export function useAccounts() {
return useQuery({
queryKey: queryKeys.accounts.all,
queryFn: accountApi.list,
})
}
// Subset hook — selects one account from the same cache
export function useAccount(id: string | undefined) {
return useQuery({
queryKey: queryKeys.accounts.byId(id ?? ''),
queryFn: () => accountApi.getById(id!),
enabled: !!id,
})
}
// Subset hook with select — derives a filtered view from cache
export function useActiveAccounts() {
return useQuery({
queryKey: queryKeys.accounts.all,
queryFn: accountApi.list,
select: (accounts: Account[]) => accounts.filter(a => a.status === 'active'),
})
}Mutation Hooks
// clientDomains/account/useAccountMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { queryKeys } from '@/constants/queryKeys'
import { accountApi } from './api'
import type { CreateAccountRequest } from '@/types/account'
export function useCreateAccount() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: accountApi.create,
onSuccess: (newAccount) => {
// Invalidate list so it refetches with new account
queryClient.invalidateQueries({ queryKey: queryKeys.accounts.all })
// Optionally: optimistically set the new entity in cache
queryClient.setQueryData(queryKeys.accounts.byId(newAccount.id), newAccount)
},
})
}
export function useUpdateAccount() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<Account> }) =>
accountApi.update(id, payload),
onSuccess: (updated) => {
queryClient.setQueryData(queryKeys.accounts.byId(updated.id), updated)
queryClient.invalidateQueries({ queryKey: queryKeys.accounts.all })
},
})
}
export function useDeleteAccount() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: accountApi.delete,
onSuccess: (_, deletedId) => {
queryClient.removeQueries({ queryKey: queryKeys.accounts.byId(deletedId) })
queryClient.invalidateQueries({ queryKey: queryKeys.accounts.all })
},
})
}Usage in Components
// app/(app)/dashboard/page.tsx
'use client'
import { useAccounts } from '@/clientDomains/account/useAccounts'
import { useCreateAccount } from '@/clientDomains/account/useAccountMutations'
export default function DashboardPage() {
const { data: accounts = [], isLoading } = useAccounts()
const createAccount = useCreateAccount()
const handleCreate = async (payload: CreateAccountRequest) => {
await createAccount.mutateAsync(payload)
}
if (isLoading) return <LoadingSpinner />
return (
<AccountList
accounts={accounts}
onCreateAccount={handleCreate}
isCreating={createAccount.isPending}
/>
)
}Axios Client (lib/api/client.ts)
The shared Axios instance used by all api.ts files:
// lib/api/client.ts
import axios from 'axios'
export const httpApiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: { 'Content-Type': 'application/json' },
timeout: 30_000,
})
// Attach auth token to every request
httpApiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Normalise errors
httpApiClient.interceptors.response.use(
(response) => response,
(error) => {
const message = error.response?.data?.detail ?? error.message
return Promise.reject(new Error(message))
}
)Rules
api.tsfiles contain only pure async functions — no hooks, nouseQuery, no side effects.- Hooks call
api.tsfunctions — they do not construct URLs or use axios directly. - All query keys reference
queryKeysfromconstants/queryKeys.ts— never inline strings. - One domain = one folder in
clientDomains/. Do not mix account logic into the message domain, etc. - Subset hooks use
selectto derive data from an existing query — they do not make a separate API call. useEffectis never used for data fetching — that is React Query's job.