Step 11 — The React Hook: useTags with TanStack Query
Step 10 gave us tagsApi — four functions that return promises. vibe-frontend/src/hooks/useTags.ts is where those promises become reactive, cached, component-ready state, using the TanStack Query fundamentals previewed back in Step 6.
Reads: useQuery
export function useTags(teamId?: string) {
const queryClient = useQueryClient();
const tagsQuery = useQuery({
queryKey: ['tags', teamId],
queryFn: () => tagsApi.list({ team_id: teamId }),
enabled: !!teamId,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
});
// ...
}
Exactly the shape from Step 6, now with a real queryFn: () => tagsApi.list({ team_id: teamId }). gcTime (garbage-collect time) controls how long an unused cache entry is kept around before being dropped — separate from staleTime, which controls when a still-cached entry is considered outdated and eligible for a background refetch.
Writes: useMutation + optimistic-ish cache updates
const createTagMutation = useMutation({
mutationFn: (data: CreateTag) => tagsApi.create(data),
onSuccess: (newTag) => {
queryClient.setQueryData<Tag[]>(['tags', teamId], (old) => {
if (!old) return [newTag];
return [...old, newTag].sort((a, b) => a.tag_name.localeCompare(b.tag_name));
});
},
});
Rather than refetch the whole tag list after creating one tag, onSuccess directly patches the existing cache entry (queryClient.setQueryData) by appending the new tag and re-sorting — the UI updates instantly with zero extra network round-trip. updateTagMutation and deleteTagMutation follow the identical shape: call the matching tagsApi method, then surgically update the cached array (map to replace, filter to remove) instead of invalidating and refetching.
const deleteTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.delete(tagId),
onSuccess: (_, tagId) => {
queryClient.setQueryData<Tag[]>(['tags', teamId], (old) => old?.filter((t) => t.id !== tagId) ?? []);
queryClient.invalidateQueries({ queryKey: ['task-tags'], refetchType: 'none' });
},
});
Deleting a tag also invalidates the separate ['task-tags'] cache (tags-attached-to-tasks) — because deleting a tag can affect data that a completely different query owns. refetchType: 'none' marks that cache stale without forcing an immediate refetch; it'll refetch next time something actually reads it.
A hook that returns exactly what a component needs
return {
tags: tagsQuery.data ?? [],
isLoading: tagsQuery.isLoading,
error: tagsQuery.error,
createTag: createTagMutation.mutateAsync,
updateTag: updateTagMutation.mutateAsync,
deleteTag: deleteTagMutation.mutateAsync,
isCreating: createTagMutation.isPending,
isUpdating: updateTagMutation.isPending,
isDeleting: deleteTagMutation.isPending,
};
This is the same "purpose-built hook, not a raw store" principle from Step 5's useTaskStopping — a component that calls useTags(teamId) never touches tagsApi, queryClient, or cache keys directly. It gets a flat object: data, loading flags, and plain async functions to call.
Checkpoint
Notice tags: tagsQuery.data ?? [] — a component using useTags never has to check "is tags undefined?" before mapping over it; the hook itself normalizes the "no data yet" case to an empty array. This is a small but deliberate ergonomics decision that every consumer benefits from once, instead of every consumer repeating a null-check.
Continue to Step 12 — The Component + a DevTools Network Trace, the last step, where useTags gets consumed by TagEditDialog and we watch the whole chain fire in the browser.