Frontend Custom Hooks¶
Custom Hook Patterns¶
All custom hooks live in frontend/src/hooks/. Naming convention: use + noun or noun phrase (useIssues, useProjectMembers, useCompleteSprint).
Every hook wraps useQuery (read) or useMutation (write) from @tanstack/react-query, calling an API function from frontend/src/api/. Hooks never call apiClient directly — they call the typed function objects exported from the API modules.
Query hook pattern:
import { useQuery } from '@tanstack/react-query'
import { issuesApi } from '@/api/issues'
export function useIssues(projectKey: string) {
return useQuery({
queryKey: ['issues', projectKey],
queryFn: () => issuesApi.list(projectKey).then(r => r.data),
})
}
export function useIssue(projectKey: string, issueKey: string) {
return useQuery({
queryKey: ['issues', projectKey, issueKey],
queryFn: () => issuesApi.get(projectKey, issueKey).then(r => r.data),
})
}
Mutation hook pattern — with cache invalidation:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { issuesApi } from '@/api/issues'
export function useCreateIssue(projectKey: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; type?: string; priority?: string }) =>
issuesApi.create(projectKey, data).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['issues', projectKey] }),
})
}
Query Key Conventions¶
Query keys are always arrays. Project-scoped keys always include projectKey as the second element to prevent cross-project cache collisions.
| Key pattern | Hook |
|---|---|
['projects'] |
useProjects |
['projects', key] |
useProject |
['members', projectKey] |
useProjectMembers |
['labels', projectKey] |
useLabels, useCreateLabel, useUpdateLabel, useDeleteLabel |
['issues', projectKey] |
useIssues |
['issues', projectKey, issueKey] |
useIssue |
['board', projectKey] |
useBoard |
['backlog', projectKey] |
useBacklog |
['sprints', projectKey] |
useSprints |
['comments', projectKey, issueKey] |
useComments |
['activity', projectKey, issueKey] |
useActivity |
['attachments', projectKey, issueKey] |
useAttachments |
['burndown', projectKey, sprintId] |
useBurndown |
['velocity', projectKey] |
useVelocity |
['cycleTimeAggregate', projectKey] |
useCycleTimeAggregate |
['notifications', page] |
useNotifications |
['notifications', 'unread-count'] |
useUnreadCount |
['workflow-editor', projectKey] |
useWorkflowEditor |
['automation', projectKey] |
useAutomationRules |
['automation', 'system'] |
useSystemRules |
['api-keys', projectKey] |
useApiKeys |
['webhooks', projectKey] |
useWebhooks |
['webhook-deliveries', projectKey, webhookId] |
useWebhookDeliveries |
['integrations', projectKey] |
useProjectIntegrations |
['dashboard', projectKey] |
useProjectDashboard |
['me'] |
useMe |
Mutation Patterns¶
Every mutation hook follows the same structure:
- Acquire
queryClientviauseQueryClient(). - Call the API function in
mutationFn. - Invalidate the affected query key(s) in
onSuccess.
Multi-key invalidation — when a mutation affects several cached resources, invalidate all of them:
export function useStartSprint(projectKey: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (sprintId: string) => sprintsApi.start(projectKey, sprintId).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['sprints', projectKey] })
qc.invalidateQueries({ queryKey: ['board', projectKey] })
qc.invalidateQueries({ queryKey: ['backlog', projectKey] })
},
})
}
Conditional enabling — pass enabled: !!param to useQuery when a required parameter may be absent:
export function useBurndown(projectKey: string, sprintId: string | null) {
return useQuery({
queryKey: ['burndown', projectKey, sprintId],
queryFn: () => reportsApi.burndown(projectKey, sprintId!).then(r => r.data),
enabled: !!sprintId,
})
}
Optimistic Update Pattern¶
No hooks currently use onMutate / onError / onSettled for optimistic updates. All mutations use invalidation-on-success, which triggers a background refetch. If you need to add an optimistic update, follow the React Query documentation pattern:
onMutate: snapshot the current cache, apply the optimistic change, return the snapshot as context.onError: roll back to the snapshot using the returned context.onSettled: always re-invalidate to sync with the server.
WebSocket Hook¶
useProjectSocket(projectKey) in frontend/src/hooks/useProjectSocket.ts is a side-effect-only hook — it returns nothing. It opens a STOMP WebSocket connection over /ws-stomp and calls queryClient.invalidateQueries when the server pushes ISSUE_MOVED or SPRINT_UPDATED events. Call it once at the top of any page that needs live board updates.
The hook authenticates by reading localStorage.getItem('accessToken') for the STOMP connectHeaders. This is the one sanctioned place outside of apiClient that reads the token directly.
Extension Points¶
To add a hook for a new resource:
- Add API functions to
frontend/src/api/<module>.ts(or a new file importingapiClient). - Define the query key as
['resource', projectKey]— includeprojectKeyfor any project-scoped data. - Create
frontend/src/hooks/useX.tsexportinguseX(query) and mutation hooks as needed. - Call
qc.invalidateQueries({ queryKey: [...] })in every mutation'sonSuccess— always pass aqueryKeyfilter, never callqc.invalidateQueries()with no arguments.
Common Pitfalls¶
- Never call API functions directly in components. All API access must go through a
useXhook. - Query keys must be arrays, not strings. Use
['issues', projectKey], not'issues'or`issues-${projectKey}`. - Always include
projectKeyin project-scoped query keys. Omitting it causes cross-project cache collisions when the user switches projects without a page reload. - Do not call
qc.invalidateQueries()without aqueryKeyfilter. This invalidates the entire cache and triggers unnecessary refetches across all open queries.
Example: Complete Query + Mutation Pair¶
frontend/src/hooks/useComments.ts — a query hook and a mutation with multi-key invalidation:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { commentsApi } from '@/api/comments'
export function useComments(projectKey: string, issueKey: string) {
return useQuery({
queryKey: ['comments', projectKey, issueKey],
queryFn: () => commentsApi.list(projectKey, issueKey).then(r => r.data),
})
}
export function useAddComment(projectKey: string, issueKey: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: string) => commentsApi.create(projectKey, issueKey, body).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['comments', projectKey, issueKey] })
qc.invalidateQueries({ queryKey: ['activity', projectKey, issueKey] })
},
})
}
The file also exports useDeleteComment and useEditComment, which follow the same mutation pattern with identical onSuccess invalidation of ['comments', ...] and ['activity', ...].