Documentation

React Query Patterns

Server state as single source of truth, centralised query keys, no useEffect for data fetching.

Show:

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, error states
  • 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 queryKeys from constants/queryKeys.ts
  • Type Safety: Type all API responses
  • Error Handling: Use React Query's built-in error states
  • Loading States: Use isLoading, isFetching appropriately

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 const

Hierarchy 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.ts files contain only pure async functions — no hooks, no useQuery, no side effects.
  • Hooks call api.ts functions — they do not construct URLs or use axios directly.
  • All query keys reference queryKeys from constants/queryKeys.ts — never inline strings.
  • One domain = one folder in clientDomains/. Do not mix account logic into the message domain, etc.
  • Subset hooks use select to derive data from an existing query — they do not make a separate API call.
  • useEffect is never used for data fetching — that is React Query's job.