Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .agents/skills/frontend-data-fetching/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: frontend-data-fetching
description: 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".
description: 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, apiFetch in a mutationFn, 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".
---

# Frontend Data Fetching (TanStack Query + apiOptions)
Expand Down Expand Up @@ -48,14 +48,14 @@ useQuery<ResponseType, RequestError>({...})
// ✅ Let types be inferred — annotate the mutationFn/queryFn instead
useMutation({
mutationFn: (variables: MyVariables) =>
fetchMutation<MyResponse>({...}),
apiFetch<MyResponse>({queryKey: [url, {...}]}),
})
```

Specific rules:

1. **Type the `mutationFn` parameters**, not the hook/function generics. The variables type flows from the `mutationFn` signature.
2. **Use `fetchMutation<T>`** to type the return value — the generic on `fetchMutation` is correct because it types the API response.
2. **Use `apiFetch<T>`** to type the return value — the generic on `apiFetch` is correct because it types the API response. A `mutationFn` returning `apiFetch(...)` resolves to `ApiResponse<T>` (`{json, headers, status}`), so `onSuccess`/`mutateAsync` consumers read `.json`.
3. **Never type the error generic as `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.
4. **Never explicitly type the context** — it is inferred from what `onMutate` returns. Creating a separate `type FooContext = {...}` and passing it as a generic is unnecessary.
5. **Same rule applies to queries** — `useQuery`, `queryOptions`, `useInfiniteQuery`, etc. Types flow from `queryFn` and `select`.
Expand All @@ -65,7 +65,7 @@ Specific rules:
type MyContext = {previousData: Item[]};

mutationOptions<Item, RequestError, UpdateItemVars, MyContext>({
mutationFn: variables => fetchMutation({...}),
mutationFn: variables => apiFetch({queryKey: [url, {...}]}),
onMutate: async () => {
const previousData = queryClient.getQueryData(itemQueryOptions);
return {previousData};
Expand All @@ -78,7 +78,7 @@ mutationOptions<Item, RequestError, UpdateItemVars, MyContext>({
// ✅ Everything is inferred
mutationOptions({
mutationFn: (variables: UpdateItemVars) =>
fetchMutation<Item>({...}),
apiFetch<Item>({queryKey: [url, {...}]}),
onMutate: async () => {
const previousData = queryClient.getQueryData(itemQueryOptions);
return {previousData};
Expand Down
54 changes: 24 additions & 30 deletions .agents/skills/generate-frontend-forms/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,17 +545,15 @@ import {useMutation} from '@tanstack/react-query';

import {setFieldErrors} from '@sentry/scraps/form';

import {fetchMutation} from 'sentry/utils/queryClient';
import {apiFetch} from 'sentry/utils/api/apiFetch';
import {RequestError} from 'sentry/utils/requestError/requestError';
import {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors';

function MyForm() {
const mutation = useMutation({
mutationFn: (data: {email: string; username: string}) => {
return fetchMutation({
url: '/users/',
method: 'POST',
data,
return apiFetch({
queryKey: ['/users/', {method: 'POST', data}],
});
},
});
Expand Down Expand Up @@ -626,7 +624,7 @@ import {z} from 'zod';

import {AutoSaveForm} from '@sentry/scraps/form';

import {fetchMutation} from 'sentry/utils/queryClient';
import {apiFetch} from 'sentry/utils/api/apiFetch';

const schema = z.object({
displayName: z.string().min(1, 'Display name is required'),
Expand All @@ -640,15 +638,13 @@ function SettingsForm() {
initialValue={user.displayName}
mutationOptions={{
mutationFn: data => {
return fetchMutation({
url: '/user/',
method: 'PUT',
data,
return apiFetch({
queryKey: ['/user/', {method: 'PUT', data}],
});
},
onSuccess: data => {
onSuccess: response => {
// Update React Query cache
queryClient.setQueryData(['user'], old => ({...old, ...data}));
queryClient.setQueryData(['user'], old => ({...old, ...response.json}));
},
}}
>
Expand Down Expand Up @@ -768,15 +764,13 @@ confirm={value => value === 'delete' ? 'This will permanently delete all data!'
```tsx
import {useMutation} from '@tanstack/react-query';

import {fetchMutation} from 'sentry/utils/queryClient';
import {apiFetch} from 'sentry/utils/api/apiFetch';

function MyForm() {
const mutation = useMutation({
mutationFn: (data: FormData) => {
return fetchMutation({
url: '/endpoint/',
method: 'POST',
data,
return apiFetch({
queryKey: ['/endpoint/', {method: 'POST', data}],
});
},
onSuccess: () => {
Expand Down Expand Up @@ -898,9 +892,9 @@ onSubmit: ({value}) => {
return mutation.mutateAsync(value);
};

// ✅ Use mutations with fetchMutation and .catch(() => {})
// ✅ Use mutations with apiFetch and .catch(() => {})
const mutation = useMutation({
mutationFn: data => fetchMutation({url: '/users/', method: 'POST', data}),
mutationFn: data => apiFetch({queryKey: ['/users/', {method: 'POST', data}]}),
});

onSubmit: ({value}) => {
Expand Down Expand Up @@ -940,17 +934,17 @@ z.string().min(1, 'Email address is required');
```tsx
// ❌ Don't use toasts for auto-save status
mutationOptions={{
mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
mutationFn: (data) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}),
onSuccess: () => {
addSuccessMessage('Saved!'); // ❌ noisy and disruptive
},
}}

// ✅ Rely on built-in inline indicators (spinner, checkmark, warning icon)
mutationOptions={{
mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
onSuccess: (data) => {
queryClient.setQueryData(['user'], old => ({...old, ...data}));
mutationFn: (data) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}),
onSuccess: (response) => {
queryClient.setQueryData(['user'], old => ({...old, ...response.json}));
// No toast needed - AutoSaveForm shows a checkmark automatically
},
}}
Expand All @@ -963,14 +957,14 @@ Always update the data store or cache in `onSuccess`. Without this, toggling a f
```tsx
// ❌ Don't forget to update the cache after auto-save
mutationOptions={{
mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
mutationFn: (data) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}),
}}

// ✅ Update React Query cache on success
mutationOptions={{
mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
onSuccess: (data) => {
queryClient.setQueryData(['user'], old => ({...old, ...data}));
mutationFn: (data) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}),
onSuccess: (response) => {
queryClient.setQueryData(['user'], old => ({...old, ...response.json}));
},
}}
```
Expand All @@ -988,12 +982,12 @@ useMutation<Response, RequestError, Variables>({...})

// ❌ Don't use generic types - breaks field type narrowing
const opts = mutationOptions({
mutationFn: (data: Record<string, unknown>) => fetchMutation({...}),
mutationFn: (data: Record<string, unknown>) => apiFetch({queryKey: [url, {...}]}),
});

// ❌ Don't tie mutation type to the zod schema
const opts = mutationOptions({
mutationFn: (data: Partial<z.infer<typeof preferencesSchema>>) => fetchMutation({...}),
mutationFn: (data: Partial<z.infer<typeof preferencesSchema>>) => apiFetch({queryKey: [url, {...}]}),
});

// ❌ Don't explicitly type context — it's inferred from onMutate return
Expand All @@ -1004,7 +998,7 @@ type MyContext = {previousData: UserDetails};
// ✅ Use the API's data type on mutationFn, let everything else be inferred
const opts = mutationOptions({
mutationFn: (data: Partial<UserDetails>) =>
fetchMutation<UserDetails>({...}),
apiFetch<UserDetails>({queryKey: [url, {...}]}),
});
```

Expand Down
47 changes: 22 additions & 25 deletions .agents/skills/migrate-frontend-forms/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,11 @@ The `getData` function transformed field data before sending to the API. In the
mutationFn: data => {
// Transform data before API call (equivalent to getData)
const transformed = {options: data};
return fetchMutation({
url: `/projects/${organization.slug}/${project.slug}/`,
method: 'PUT',
data: transformed,
return apiFetch({
queryKey: [
`/projects/${organization.slug}/${project.slug}/`,
{method: 'PUT', data: transformed},
],
});
},
}}
Expand All @@ -178,10 +179,8 @@ The `getData` function transformed field data before sending to the API. In the
```tsx
mutationOptions={{
mutationFn: fieldData => {
return fetchMutation({
url: `/projects/${org}/${project}/`,
method: 'PUT',
data: {options: fieldData}, // getData equivalent
return apiFetch({
queryKey: [`/projects/${org}/${project}/`, {method: 'PUT', data: {options: fieldData}}], // getData equivalent
});
},
}}
Expand All @@ -195,21 +194,21 @@ The `mutationFn` should be typed with the API's data type (e.g., `Partial<Organi
// ❌ Don't use generic types - breaks field type narrowing
mutationOptions={{
mutationFn: (data: Record<string, unknown>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
return apiFetch({queryKey: ['/user/', {method: 'PUT', data: {options: data}}]});
},
}}

// ❌ Don't tie mutation type to the zod schema
mutationOptions={{
mutationFn: (data: Partial<z.infer<typeof preferencesSchema>>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
return apiFetch({queryKey: ['/user/', {method: 'PUT', data: {options: data}}]});
},
}}

// ✅ Use the API's data type
mutationOptions={{
mutationFn: (data: Partial<UserDetails>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
return apiFetch({queryKey: ['/user/', {method: 'PUT', data: {options: data}}]});
},
}}
```
Expand All @@ -221,7 +220,7 @@ Make sure the zod schema's types are compatible with (i.e., assignable to) the A
```tsx
// ❌ Generics on useMutation — NEVER do this
const mutation = useMutation<CodeOwner, RequestError, [Payload]>({
mutationFn: ([payload]) => fetchMutation({url, method: 'POST', data: payload}),
mutationFn: ([payload]) => apiFetch({queryKey: [url, {method: 'POST', data: payload}]}),
});

// ❌ Generics on mutationOptions — NEVER do this either
Expand All @@ -233,19 +232,17 @@ type MyContext = {changeId: string};
// ❌ RequestError as error generic — it's a type assertion in disguise
// Other things can go wrong that would NOT yield a RequestError

// ✅ Type the mutationFn payload; fetchMutation<T> carries the return type
// ✅ Type the mutationFn payload; apiFetch<T> carries the return type
const mutation = useMutation({
mutationFn: (payload: {codeMappingId: string; raw: string}) =>
fetchMutation<CodeOwner>({
url: `/projects/${org}/${project}/codeowners/`,
method: 'POST',
data: payload,
apiFetch<CodeOwner>({
queryKey: [`/projects/${org}/${project}/codeowners/`, {method: 'POST', data: payload}],
}),
});

// ✅ Context is inferred from onMutate, error is Error by default
mutationOptions({
mutationFn: (variables: MyVars) => fetchMutation<MyResponse>({...}),
mutationFn: (variables: MyVars) => apiFetch<MyResponse>({queryKey: [url, {...}]}),
onMutate: async () => {
return {changeId: uniqueId()}; // context type inferred from this
},
Expand Down Expand Up @@ -392,7 +389,7 @@ import {addSuccessMessage} from 'sentry/actionCreators/indicator';
schema={schema}
initialValue={project.fingerprintingRules}
mutationOptions={{
mutationFn: data => fetchMutation({...}),
mutationFn: data => apiFetch({queryKey: [url, {...}]}),
onSuccess: () => {
// Custom success message (equivalent to saveMessage)
addSuccessMessage(t('Changing fingerprint rules will apply to future events only.'));
Expand All @@ -419,16 +416,16 @@ The `formatMessageValue` controlled how the changed value appeared in success to

```tsx
mutationOptions={{
mutationFn: data => fetchMutation({...}),
mutationFn: data => apiFetch({queryKey: [url, {...}]}),
onSuccess: () => {
// Just show the message, no value (equivalent to formatMessageValue: false)
addSuccessMessage(t('Rules updated'));
},
}}

// Or if you want to show a formatted value:
onSuccess: (data) => {
addSuccessMessage(t('Slug changed to %s', data.slug));
onSuccess: (response) => {
addSuccessMessage(t('Slug changed to %s', response.json.slug));
},
```

Expand Down Expand Up @@ -473,7 +470,7 @@ const form = useScrapsForm({
schema={schema}
initialValue={settings.enabled}
mutationOptions={{
mutationFn: data => fetchMutation({...}),
mutationFn: data => apiFetch({queryKey: [url, {...}]}),
onError: () => {
// The field automatically shows error state via TanStack Query
// If you need to reset the value, you can pass a reset callback
Expand Down Expand Up @@ -530,7 +527,7 @@ const slugSchema = z.object({
function SlugForm({project}: {project: Project}) {
const mutation = useMutation({
mutationFn: (data: {slug: string}) =>
fetchMutation({url: `/projects/${org}/${project.slug}/`, method: 'PUT', data}),
apiFetch({queryKey: [`/projects/${org}/${project.slug}/`, {method: 'PUT', data}]}),
});

const form = useScrapsForm({
Expand Down Expand Up @@ -726,7 +723,7 @@ For a nullable number field, use `z.number().nullable()` in the form schema and
## Migration Checklist

- [ ] Replace JsonForm/FormModel with useScrapsForm or AutoSaveForm
- [ ] No generics on `useMutation` — type the `mutationFn` payload and use `fetchMutation<T>` for the return type
- [ ] No generics on `useMutation` — type the `mutationFn` payload and use `apiFetch<T>` for the return type
- [ ] When using `useScrapsForm` with a Save button: mutation runs in `onSubmit`, triggered by `<form.SubmitButton>` (no form that's never submitted)
- [ ] Convert field config objects to JSX AppField components
- [ ] Validate conditionally required fields on the rendered `AppField`, with a permissive form schema
Expand Down
4 changes: 2 additions & 2 deletions static/app/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ export class Client {
/**
* Initiate a request to the backend API.
*
* @deprecated Use `useApiQuery` or `useMutation` with `fetchDataQuery` and `fetchMutation` instead.
* @deprecated Use `useQuery` with `apiOptions`, or `useMutation` with `apiFetch` instead.
* See https://develop.sentry.dev/frontend/network-requests/ for more.
*/
request(path: string, options: Readonly<RequestOptions> = {}): Request {
Expand Down Expand Up @@ -665,7 +665,7 @@ export class Client {
/**
* Initiate a request to the backend API.
*
* @deprecated Use `useApiQuery` or `useMutation` with `fetchDataQuery` and `fetchMutation` instead.
* @deprecated Use `useQuery` with `apiOptions`, or `useMutation` with `apiFetch` instead.
* See https://develop.sentry.dev/frontend/network-requests/ for more.
*/
requestPromise<IncludeAllArgsType extends boolean>(
Expand Down
Loading
Loading