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.