Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Fetch data in Sentry's frontend with TanStack Query and apiOptions. Use when adding or editing React code in static/ that calls the API — useQuery/useMutation/useInfiniteQuery, apiOptions, queryOptions/mutationOptions, fetchMutation, reading response headers/pagination, or conditional fetching. Trigger on "fetch data", "add an API call", "useQuery", "useMutation", "apiOptions", "queryFn", "pagination headers", "X-Hits", or "why is my query type wrong".
.claude/skills/getsentry-frontend-data-fetching/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 3% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 28% | 0% |
| case-03 | ✗→✓ | ▲ Improved | -3% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 23% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 37% | 0% |
Use apiOptions with useQuery from TanStack Query. Do not use useApiQuery, getApiQueryData, or setApiQueryData — they are deprecated.
typescriptimport {skipToken, useQuery} from '@tanstack/react-query'; import {apiOptions} from 'sentry/utils/api/apiOptions'; // Basic usage const query = useQuery( apiOptions.as<ResponseType>()('/organizations/$organizationIdOrSlug/endpoint/', { path: {organizationIdOrSlug: organization.slug}, staleTime: 30_000, }) ); // Conditional fetching — pass skipToken as path to disable the query const query = useQuery( apiOptions.as<ResponseType>()('/organizations/$organizationIdOrSlug/items/$itemId/', { path: itemId ? {organizationIdOrSlug: organization.slug, itemId} : skipToken, staleTime: 30_000, }) );
Key rules:
staleTime is required — you must choose a value (0, a number in ms, Infinity, or 'static').apiOptions, not over useQuery. Return the options object so consumers can pass it to useQuery, useQueries, prefetchQuery, etc.{json, headers}, not just the body. apiOptions uses select to extract .json by default, but getQueryData, setQueryData, retry functions, and predicate callbacks all receive the raw ApiResponse<T> shape.api.requestPromise for a Query - it returns the wrong structure. If you must make a manual queryFn, use apiFetch.CRITICAL: Never pass type parameters to useQuery, useMutation, mutationOptions, queryOptions, or any TanStack Query function at the call site. Let TypeScript infer types from your queryFn/mutationFn and callbacks. Passing call-site generics defeats inference, hides bugs, and creates maintenance burden.
typescript// ❌ NEVER pass generics to useQuery, useMutation, mutationOptions, etc. useMutation<ResponseType, RequestError, Variables, Context>({...}) mutationOptions<ResponseType, RequestError, Variables, Context>({...}) useQuery<ResponseType, RequestError>({...}) // ✅ Let types be inferred — annotate the mutationFn/queryFn instead useMutation({ mutationFn: (variables: MyVariables) => fetchMutation<MyResponse>({...}), })
Specific rules:
mutationFn parameters, not the hook/function generics. The variables type flows from the mutationFn signature.fetchMutation<T> to type the return value — the generic on fetchMutation is correct because it types the API response.RequestError — that's a type assertion in disguise. The error is Error by default. Use runtime narrowing (if (error instanceof RequestError)) when you need RequestError-specific properties.onMutate returns. Creating a separate type FooContext = {...} and passing it as a generic is unnecessary.useQuery, queryOptions, useInfiniteQuery, etc. Types flow from queryFn and select.typescript// ❌ Explicit context type + error assertion type MyContext = {previousData: Item[]}; mutationOptions<Item, RequestError, UpdateItemVars, MyContext>({ mutationFn: variables => fetchMutation({...}), onMutate: async () => { const previousData = queryClient.getQueryData(itemQueryOptions); return {previousData}; }, onError: (_error, _variables, context) => { queryClient.setQueryData(key, context?.previousData); }, }) // ✅ Everything is inferred mutationOptions({ mutationFn: (variables: UpdateItemVars) => fetchMutation<Item>({...}), onMutate: async () => { const previousData = queryClient.getQueryData(itemQueryOptions); return {previousData}; }, onError: (_error, _variables, context) => { // context type is inferred from onMutate return queryClient.setQueryData(key, context?.previousData); }, })
By default, apiOptions selects only the JSON body from the response. If you need response headers (e.g., Link for pagination or X-Hits / X-Max-Hits for total counts), override select with selectJsonWithHeaders:
typescriptimport {useQuery} from '@tanstack/react-query'; import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; const {data} = useQuery({ ...apiOptions.as<Item[]>()('/organizations/$organizationIdOrSlug/items/', { path: {organizationIdOrSlug: organization.slug}, query: {cursor, per_page: 25}, staleTime: 0, }), select: selectJsonWithHeaders, }); // data is ApiResponse<Item[]> — an object with `json` and `headers` const items = data?.json ?? []; const pageLinks = data?.headers.Link; // string | undefined const totalHits = data?.headers['X-Hits']; // number | undefined const maxHits = data?.headers['X-Max-Hits']; // number | undefined
Note that X-Hits and X-Max-Hits are already parsed to number | undefined — no parseInt needed.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-02 | fail→pass | 14,332 | 8,296 | -42% | 1 | 1 | 0% | 2,917 | 3,014 | +3% | 0 | 0 | — |
case-01 | fail→pass | 15,662 | 10,909 | -30% | 1 | 1 | 0% | 2,469 | 3,160 | +28% | 0 | 0 | — |
case-03 | fail→pass | 19,141 | 7,190 | -62% | 1 | 1 | 0% | 2,919 | 2,838 | -3% | 0 | 0 | — |
case-04 | fail→pass | 16,761 | 13,229 | -21% | 1 | 1 | 0% | 2,743 | 3,361 | +23% | 0 | 0 | — |
case-05 | pass→pass | 23,544 | 14,163 | -40% | 1 | 1 | 0% | 3,564 | 3,965 | +11% | 0 | 0 | — |
case-06 | fail→pass | 13,622 | 12,675 | -7% | 1 | 1 | 0% | 2,492 | 3,402 | +37% | 0 | 0 | — |
case-07 | fail→pass | 21,348 | 7,760 | -64% | 1 | 1 | 0% | 2,781 | 2,866 | +3% | 0 | 0 | — |
case-08 | fail→pass | 15,136 | 5,955 | -61% | 1 | 1 | 0% | 2,289 | 2,517 | +10% | 0 | 0 | — |
case-09 | fail→pass | 14,552 | 7,200 | -51% | 1 | 1 | 0% | 2,652 | 2,708 | +2% | 0 | 0 | — |
case-10 | pass→pass | 12,045 | 6,768 | -44% | 1 | 1 | 0% | 2,121 | 2,607 | +23% | 0 | 0 | — |
case-11 | fail→pass | 15,387 | 6,371 | -59% | 1 | 1 | 0% | 2,663 | 2,589 | -3% | 0 | 0 | — |
case-12 | fail→pass | 18,801 | 7,659 | -59% | 1 | 1 | 0% | 2,825 | 2,844 | +1% | 0 | 0 | — |
case-13 | fail→fail | 13,434 | 13,760 | +2% | 1 | 1 | 0% | 2,465 | 3,434 | +39% | 0 | 0 | — |
case-14 | pass→pass | 13,580 | 7,281 | -46% | 1 | 1 | 0% | 2,541 | 2,800 | +10% | 0 | 0 | — |
case-15 | fail→pass | 11,254 | 11,358 | +1% | 1 | 1 | 0% | 1,924 | 3,010 | +56% | 0 | 0 | — |
case-16 | fail→pass | 13,356 | 11,113 | -17% | 1 | 1 | 0% | 1,866 | 3,033 | +63% | 0 | 0 | — |
case-17 | fail→pass | 14,117 | 6,963 | -51% | 1 | 1 | 0% | 2,168 | 2,660 | +23% | 0 | 0 | — |
case-18 | fail→pass | 15,844 | 8,734 | -45% | 1 | 1 | 0% | 3,051 | 2,744 | -10% | 0 | 0 | — |
case-19 | fail→pass | 12,046 | 14,077 | +17% | 1 | 1 | 0% | 2,114 | 3,395 | +61% | 0 | 0 | — |
case-20 | pass→pass | 23,990 | 24,052 | +0% | 1 | 1 | 0% | 3,737 | 4,987 | +33% | 0 | 0 | — |
case-21 | pass→pass | 11,869 | 6,456 | -46% | 1 | 1 | 0% | 1,726 | 2,617 | +52% | 0 | 0 | — |
case-22 | pass→pass | 17,205 | 14,775 | -14% | 1 | 1 | 0% | 2,783 | 3,753 | +35% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +68 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.