Documentation

Hook Design Patterns

Source/subset hooks, mutation hooks, and optimistic updates with React Query.

Show:

Framework implementations

Abstract reference: 08-frontend-architecture/01-react-query-patterns.md


The Source/Subset Pattern

A source hook fetches the authoritative dataset for a domain. Subset hooks derive specific views from the same cache entry using React Query's select option — they do not trigger a separate network request.

// Source hook — fetches all messages for an account
export function useMessages(accountId: string | undefined) {
  return useQuery({
    queryKey: queryKeys.messages.byAccountId(accountId ?? ''),
    queryFn: () => messageApi.listByAccount(accountId!),
    enabled: !!accountId,
  })
}
 
// Subset hook — derives unread messages from the same cache entry
export function useUnreadMessages(accountId: string | undefined) {
  return useQuery({
    queryKey: queryKeys.messages.byAccountId(accountId ?? ''),
    queryFn: () => messageApi.listByAccount(accountId!),
    enabled: !!accountId,
    select: (messages) => messages.filter(m => !m.read_at),
  })
}
 
// Subset hook — finds a single message by ID from the list cache
export function useMessageById(accountId: string | undefined, messageId: string) {
  return useQuery({
    queryKey: queryKeys.messages.byAccountId(accountId ?? ''),
    queryFn: () => messageApi.listByAccount(accountId!),
    enabled: !!accountId && !!messageId,
    select: (messages) => messages.find(m => m.id === messageId),
  })
}

The select function runs after each cache update — all three hooks share one network request and one cache entry.


Hook Composition

Build complex hooks by composing simpler ones:

// Composed hook — combines account and its messages
export function useAccountWithMessages(accountId: string | undefined) {
  const accountQuery = useAccount(accountId)
  const messagesQuery = useMessages(accountId)
 
  return {
    account: accountQuery.data,
    messages: messagesQuery.data ?? [],
    isLoading: accountQuery.isLoading || messagesQuery.isLoading,
    error: accountQuery.error ?? messagesQuery.error,
  }
}

Mutation Hooks

Mutation hooks encapsulate write operations and their cache effects:

export function useSendMessage() {
  const queryClient = useQueryClient()
 
  return useMutation({
    mutationFn: messageApi.send,
    onSuccess: (newMessage) => {
      // Add new message to the list cache immediately
      queryClient.setQueryData(
        queryKeys.messages.byAccountId(newMessage.account_id),
        (prev: Message[] = []) => [...prev, newMessage]
      )
    },
    onError: (error) => {
      // Error is surfaced via mutation.error — no toast here
      console.error('Failed to send message:', error)
    },
  })
}

Optimistic Updates

For operations where immediate feedback matters, update the cache before the server confirms:

export function useMarkMessageRead() {
  const queryClient = useQueryClient()
 
  return useMutation({
    mutationFn: messageApi.markRead,
    onMutate: async ({ messageId, accountId }) => {
      // Cancel any in-flight refetches
      await queryClient.cancelQueries({ queryKey: queryKeys.messages.byAccountId(accountId) })
 
      // Snapshot previous value for rollback
      const previous = queryClient.getQueryData(queryKeys.messages.byAccountId(accountId))
 
      // Optimistically update the cache
      queryClient.setQueryData(
        queryKeys.messages.byAccountId(accountId),
        (messages: Message[] = []) =>
          messages.map(m => m.id === messageId ? { ...m, read_at: new Date().toISOString() } : m)
      )
 
      return { previous }
    },
    onError: (_err, { accountId }, context) => {
      // Rollback on error
      if (context?.previous) {
        queryClient.setQueryData(queryKeys.messages.byAccountId(accountId), context.previous)
      }
    },
    onSettled: (_data, _err, { accountId }) => {
      // Always refetch to sync with server truth
      queryClient.invalidateQueries({ queryKey: queryKeys.messages.byAccountId(accountId) })
    },
  })
}

Abstract reference: 08-frontend-architecture/05-optimistic-updates.md


Local State Hooks

For UI-only state that does not involve server data, use plain React hooks:

// Manages form state — no React Query needed
export function useAccountForm(initial?: Partial<Account>) {
  const [values, setValues] = useState({
    email: initial?.email ?? '',
    display_name: initial?.display_name ?? '',
  })
  const [errors, setErrors] = useState<Record<string, string>>({})
 
  const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
    setValues(prev => ({ ...prev, [field]: e.target.value }))
    setErrors(prev => ({ ...prev, [field]: '' }))
  }
 
  const validate = (): boolean => {
    const newErrors: Record<string, string> = {}
    if (!values.email) newErrors.email = 'Email is required'
    if (!values.display_name) newErrors.display_name = 'Name is required'
    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }
 
  return { values, errors, handleChange, validate }
}

Rules

  • Never use useEffect to fetch server data. Use useQuery.
  • Never duplicate a server fetch — if two components need the same data, they both call the same source hook (React Query deduplicates the request).
  • Subset hooks use select — they never call api.ts functions directly.
  • Mutation hooks own their cache invalidation logic — components call mutate() and receive the result, they do not manually call invalidateQueries.
  • Local (UI) state stays in useState or custom local hooks — it never goes into React Query.