Hook Design Patterns
Source/subset hooks, mutation hooks, and optimistic updates with React Query.
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
useEffectto fetch server data. UseuseQuery. - 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 callapi.tsfunctions directly. - Mutation hooks own their cache invalidation logic — components call
mutate()and receive the result, they do not manually callinvalidateQueries. - Local (UI) state stays in
useStateor custom local hooks — it never goes into React Query.