diff --git a/develop-docs/frontend/network-requests.mdx b/develop-docs/frontend/network-requests.mdx index 13c2d6c0d64ca..05658c282ab20 100644 --- a/develop-docs/frontend/network-requests.mdx +++ b/develop-docs/frontend/network-requests.mdx @@ -3,160 +3,304 @@ title: Network Requests sidebar_order: 80 --- -There are multiple ways to make network requests in the Sentry app, but React Query (`sentry/utils/queryClient.tsx`) is the preferred way to do so. In this guide we will explore how to effectively request and update data. +There are multiple ways to make network requests in the Sentry app. This guide covers the recommended approach using `apiOptions` with TanStack Query, as well as mutations and testing patterns. -## How we use React Query +## Data Fetching with `apiOptions` -For the most part, we use React Query as-is. Please refer to the [official docs](https://tanstack.com/query/latest/docs/react/overview) for most questions about options and usage. You’ll notice that we do wrap the library exports in the file `sentry/utils/queryClient.tsx` which applies a few modifications for ease of use: +`apiOptions` is a type-safe factory for creating [TanStack Query `queryOptions`](https://tanstack.com/query/latest/docs/framework/react/guides/query-options) that hit Sentry API endpoints. It replaces the legacy `useApiQuery` hook and is the recommended way to fetch data in this codebase. -- `useApiQuery` wraps `useQuery` and should be used whenever possible. - - It provides a default value for `queryFn`, which uses `queryKey` to generate request URL. - - It stores extra data from the API response, including headers and status codes. - - It requires a value to be provided for the option `staleTime`, which defaults to `0` normally. Making this explicit ensures that consumers are aware of how often the query will refetch data. See the [section on stale time](#what-to-use-for-staletime) for more information. - - `refetchOnWindowFocus` defaults to `false` instead of `true`. This is once again intended to make refetches more intentional. -- `setApiQueryData` wraps `setQueryData` for use with `useApiQuery`. See the [section on updating the cache](#updating-your-query-data) for more information. +### Why not a hook? -## Queries (GET requests) +The key design decision behind `apiOptions` is that it produces a **query options object**, not a hook. This matters because a plain object is composable - you can pass it to any TanStack Query API that accepts options: -Queries are relatively simple. It takes a query key, makes a request, and caches the result. Any changes to the query key will automatically initiate another fetch (or cache hit). If your component isn’t always ready to make a request, you may disable it with the `enabled` option. +- `useQuery(apiOptions.as()(...))` +- `useQueries({ queries: [apiOptions.as()(...), apiOptions.as()(...)]})` +- `queryClient.fetchQuery(apiOptions.as()(...))` -### Quick start +A custom hook like `useApiQuery` can only be used inside React components. By building on `queryOptions` instead, we get one abstraction that works everywhere. -Let’s say you are making the following request: `GET /projects/?org=` +### Basic usage ```tsx -import {useApiQuery} from 'sentry/utils/queryClient'; - -type FetchProjectsResponse = Array<{id: string; name: string}>; -type ProjectsListProps = {org: string}; - -function ProjectsList({org}: ProjectsListProps) { - const { - isPending, - isError, - data: projects, - refetch, - } = useApiQuery(['/projects/', {query: {org}}], { - staleTime: 0, - }); +import {useQuery} from '@tanstack/react-query'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; + +function IssueDetail({organization, issueId}) { + const {data, isPending, isError} = useQuery( + apiOptions.as()('/organizations/$organizationIdOrSlug/issues/$issueId/', { + path: {organizationIdOrSlug: organization.slug, issueId}, + staleTime: 30_000, + }) + ); - if (isPending) { - return ; - } + // data is Group | undefined +} +``` - if (isError) { - return ; - } +The `.as()` call provides the response type (until we can infer it from the endpoint itself). - return ( -
    - {projects.map(project => ( -
  • {project.id}
  • - ))} -
+The first argument is a URL pattern from a **generated list of known API endpoints**. This list is derived from the backend route definitions and lives in `knownSentryApiUrls.generated.ts` (with a manual companion for getsentry routes in `knownGetsentryApiUrls.ts`). TypeScript restricts the URL to one of these known patterns, so typos and invalid endpoints are caught at compile time. + +URL patterns use `$param` placeholders (e.g. `$organizationIdOrSlug`, `$issueId`) which TypeScript extracts to enforce a matching `path` object. If the URL has parameters, you must supply all of them - [or pass `skipToken`](#conditional-fetching-with-skiptoken) to disable the query. + +### `staleTime` is required + +`apiOptions` requires you to pass `staleTime`. This is intentional, as it forces you to think about how long your data should be considered fresh before TanStack Query marks it stale and refetches on the next trigger (mount, window focus, key change etc.). + +- `staleTime: 0` — data is always stale; it changes often and I'm okay with excess refetches. +- `staleTime: 30_000` — "I only want to refetch at most every 30 seconds". +- `staleTime: Infinity` — data never goes stale. Use for data that doesn't change (or changes so rarely that you'll invalidate manually). +- `staleTime: 'static'` — data is fetched once and _never_ refetched, even if the Query is invalidated manually. + +Note that regardless of the value chosen for `staleTime`, values only stay in the cache for 5 minutes since last use. This can be modified with `gcTime`. + +### Conditional fetching with `skipToken` + +To conditionally disable a query, pass `skipToken` as the `path` value: + +```tsx +import {skipToken, useQuery} from '@tanstack/react-query'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; + +function ProjectDetail({organization, projectSlug}) { + const {data} = useQuery( + apiOptions.as()( + '/organizations/$organizationIdOrSlug/projects/$projectIdOrSlug/', + { + path: projectSlug + ? {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectSlug} + : skipToken, + staleTime: 30_000, + } + ) ); } ``` -### What to use for staleTime +When `skipToken` is passed, the query is disabled and won't fire. This is similar to the `enabled` option, but `skipToken` is preferred as it supports type narrowing. + +### Creating abstractions + +When an API call is used in multiple places, extract it into a reusable function that returns query options. Build these abstractions **over `apiOptions`**, not over `useQuery`: + +```tsx +import {skipToken} from '@tanstack/react-query'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; + +export function sentryAppApiOptions({appSlug}: {appSlug: string | null}) { + return apiOptions.as()('/sentry-apps/$sentryAppIdOrSlug/', { + path: appSlug ? {sentryAppIdOrSlug: appSlug} : skipToken, + staleTime: 0, + }); +} +``` + +Now any consumer can use it with `useQuery`, `prefetchQuery`, or anything else: -Consider the value you select for `staleTime` carefully. With default options, queries will refetch any time the hook has mounted or the query key has changed, so long as the query is stale. The value provided for `staleTime` is the number of milliseconds that queries stay fresh in the cache before being marked stale. Once stale, the queries will stay in the cache and be refetched in the background on the next refetch event (remount or key change). Here are some common values you might use: +```tsx +// In a component +const {data} = useQuery(sentryAppApiOptions({appSlug: 'my-app'})); -- `staleTime: Infinity` — “Once I fetch this, I never want it refetched automatically” -- `staleTime: 0` — “This data changes often and I’m okay with excess refetches” -- `staleTime: 30_000` — “I only want to refetch at most every 30 seconds” +// In a loader or prefetch +queryClient.prefetchQuery(sentryAppApiOptions({appSlug: 'my-app'})); +``` + +#### Wrapping with `queryOptions` for shared config + +If you need to add shared options like `retry: false` on top of `apiOptions`, wrap it in `queryOptions` from TanStack Query: + +```tsx +import {queryOptions, skipToken} from '@tanstack/react-query'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; + +export function projectTeamsApiOptions({orgSlug, projectSlug, cursor}) { + return queryOptions({ + ...apiOptions.as()( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/teams/', + { + path: + orgSlug && projectSlug + ? {organizationIdOrSlug: orgSlug, projectIdOrSlug: projectSlug} + : skipToken, + query: {cursor}, + staleTime: 0, + } + ), + retry: false, + }); +} +``` -Note that regardless of the value chosen for `staleTime`, values only stay in the cache for 5 minutes since last use. This can be modified with `cacheTime`. +This pattern lets you share options like `retry`, `gcTime`, or `refetchInterval` without losing type safety. -### Expected error statuses +#### Overriding options at the call site -By default, any non 2xx status code will be considered an error and retried. While this works in most cases, sometimes you do expect error statuses such as `404` and want to display the not found state immediately. You may disable retries entirely with `retry: false` or filter out specific statuses by providing a function like so: +Because abstractions like `projectTeamsApiOptions` return a plain options object, you can spread it and override or add any TanStack Query option at the call site: ```tsx -useApiQuery(..., {retry: (_, error) => error.status !== 404}); +const {data} = useQuery({ + ...projectTeamsApiOptions({orgSlug, projectSlug}), + select: response => response.json.filter(team => team.isMember), + enabled: hasPermission, +}); ``` -### Headers and pagination +Note that `select` receives the raw `ApiResponse` (see [How the cache stores data](#how-the-cache-stores-data) below), so you access the body via `response.json`. + +### How the cache stores data -Headers, including page links, can be accessed with `getResponseHeader` which is returned from `useApiQuery`: +Internally, API responses are stored in the query cache as `ApiResponse`: ```tsx -const {getResponseHeader} = useApiQuery(...); -const pageLinks = getResponseHeader?.('Link'); +type ApiResponse = { + headers: { + Link?: string; + 'X-Hits'?: number; + 'X-Max-Hits'?: number; + }; + json: T; +}; ``` -### Making your query reusable +By default, `apiOptions` sets `select: selectJson`, which extracts only the `.json` body. This means when you use `useQuery`, the `data` you get back is just `T` - the headers are stripped away. + +This distinction between what's **stored** and what's **selected** is important to understand because it affects several APIs. + +#### Getting response headers -Often, you will have a `useApiQuery` call in one place that makes the request and components elsewhere that will also require access to the same data. There are a few things to keep in mind when making these hooks reusable, so let’s look at an example: +To access response headers (e.g. `Link` for pagination, `X-Hits` for total counts), override `select` with `selectJsonWithHeaders`: ```tsx -// useFetchProjects.tsx -type ProjectsResponse = Array<{id: string; name: string}>; -type FetchProjectsParameters = {orgSlug: string}; - -export function makeFetchProjectsQueryKey({ - orgSlug, -}: FetchProjectsParameters): ApiQueryKey { - return [`/projects/`, {query: {orgSlug}}]; -} +import {useQuery} from '@tanstack/react-query'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; -export function useFetchProjects( - params: FetchProjectsParameters, - options: Partial> = {} -) { - return useApiQuery(makeFetchProjectsQueryKey(params), { +const {data} = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/items/', { + path: {organizationIdOrSlug: organization.slug}, + query: {cursor, per_page: 25}, staleTime: 0, - ...options, - }); -} + }), + select: selectJsonWithHeaders, +}); -// ProjectsPage.tsx -function ProjectsPage({orgSlug}: EventsPageProps) { - const {isPending, isError, data} = useFetchProjects({orgSlug}); - return (...) -} +// data is ApiResponse — an object with `json` and `headers` +const items = data?.json ?? []; +const pageLinks = data?.headers.Link; +const totalHits = data?.headers['X-Hits']; // number | undefined +const maxHits = data?.headers['X-Max-Hits']; // number | undefined +``` -// ProjectSubComponent.tsx -function ProjectSubComponent({orgSlug}: EventPaginationProps) { - const {data} = useFetchProjects({orgSlug}); - return (...) -} +You can also provide your own `select` function. It receives the raw `ApiResponse` and can return whatever shape you need: + +```tsx +const {data} = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/items/', { + path: {organizationIdOrSlug: organization.slug}, + staleTime: 30_000, + }), + select: response => response.json.filter(item => item.isActive), +}); +``` + +#### Type inference for `getQueryData` and `setQueryData` + +Because `apiOptions` builds on TanStack Query's `queryOptions`, the `queryKey` it produces carries type information. This means `queryClient.getQueryData` and `queryClient.setQueryData` can **infer the correct type** from the key: + +```tsx +const opts = sentryAppApiOptions({appSlug: 'my-app'}); + +// TypeScript knows this is ApiResponse | undefined +const cached = queryClient.getQueryData(opts.queryKey); + +// TypeScript enforces the correct type for the updater +queryClient.setQueryData(opts.queryKey, prev => { + // prev is ApiResponse | undefined + // ... +}); ``` -Note that we add an `options` argument to our `useFetchProjects` hook so that consumers can pass their own options. Some options that can be helpful to use are: +#### APIs that operate on the raw cache structure -- `refetchOnMount: false` - - If you are using a non-infinite value for `staleTime`, subcomponents may cause refetches when mounted. By setting `refetchOnMount: false` on the subcomponent `useApiQuery`, you can prevent this. -- `notifyOnChangeProps: ['data']` - - If you know that your data will already be in the cache and you only want to extract the data from it, use this option to prevent rerenders when other `useApiQuery` states change. -- `enabled: ` - - If this query is dependent on information that isn’t available yet, make sure to disable it until you have everything you need. - - Be aware that disabled queries will always return `isPending: true`! In these cases it is often better to use `isLoading` instead, which is the same as `isFetching && isPending`. +Any TanStack Query API that gives you direct access to cached data — rather than going through `select` — will expose the raw `ApiResponse` structure: -Also make note of the function `makeFetchProjectsQueryKey`. We extract out the query key creation to its own function so that consumers of this hook can also update and refetch without having to recreate the query key manually, which can be prone to error. +- **`queryClient.getQueryData(key)`** — returns `ApiResponse | undefined` +- **`queryClient.setQueryData(key, updater)`** — the updater receives and must return `ApiResponse` +- **`retry` function** — when using a function form, the `error` is from the raw fetch +- **`predicate` in `invalidateQueries` / `removeQueries`** — the `query.state.data` is `ApiResponse` +- **`initialData`** — must be `ApiResponse`, not just `T` +- **`placeholderData`** — receives `ApiResponse` from the previous query -Reusable queries like this can be placed in `/sentry/actionsCreators`. +This is the standard TanStack Query behavior: `select` is a client-side transform that only applies when data flows out through hooks. Everything that touches the cache directly works with the stored shape. -### Updating your query data +### Query invalidation -There are many situations where your query data becomes out of date and needs to be updated or refetched. This is usually because of some user input (e.g. creating/deleting/editing a record). If you already know what the new data should look like, you can (and should) immediately update the cache using the query client: +Because `apiOptions` produces a `queryKey`, you can use it for cache invalidation too. Pass the `.queryKey` from your abstraction to `invalidateQueries`: ```tsx -import {setQueryData, useQueryClient} from 'utils/queryClient' +const opts = sentryAppApiOptions({appSlug: 'my-app'}); -const queryClient = useQueryClient(); +// Invalidate this specific query +queryClient.invalidateQueries({queryKey: opts.queryKey}); +``` -function handleCreateProject() { - const newProject = await createProject(); - setApiQueryData(queryClient, makeFetchProjectsQueryKey(...), data => { - return data ? [...data, newProject] : data; - }); -} +Since the return value of `apiOptions` matches the `QueryFilter` that needs to be passed `invalidateQueries`, you can also pass it directly: + +```tsx +const opts = sentryAppApiOptions({ appSlug: "my-app" }); +queryClient.invalidateQueries(opts); +``` + +### Infinite queries + +For paginated endpoints that use cursor-based pagination, use `apiOptions.asInfinite`: + +```tsx +import {useInfiniteQuery} from '@tanstack/react-query'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; + +const {data, fetchNextPage, hasNextPage} = useInfiniteQuery( + apiOptions.asInfinite()('/organizations/$organizationIdOrSlug/items/', { + path: {organizationIdOrSlug: organization.slug}, + staleTime: 0, + }) +); +``` + +`asInfinite` automatically handles `Link` header parsing for cursor-based pagination via `getNextPageParam` and `getPreviousPageParam`. Its query key uses `{infinite: true}` so regular and infinite queries for the same URL don't collide. + +### Migration from `useApiQuery` + +`useApiQuery` is deprecated. To migrate: + +```tsx +// Before +const {data, isPending} = useApiQuery( + [ + getApiUrl('/organizations/$organizationIdOrSlug/projects/', { + path: {organizationIdOrSlug: organization.slug}, + query: {cursor: '1'}, + }), + ], + {staleTime: 0} +); + +// After +const {data, isPending} = useQuery( + apiOptions.as()('/organizations/$organizationIdOrSlug/projects/', { + path: {organizationIdOrSlug: organization.slug}, + query: {cursor: '1'}, + staleTime: 0, + }) +); ``` -Note the use of `setApiQueryData`. Use this helper function when using `useApiQuery` because it deals setting the full API response data for you. +Key differences: -If you know the data is out of date, but don’t know what the new data should look like, you can invalidate the cache with `invalidateQueries`. [See the docs](https://tanstack.com/query/v4/docs/react/guides/query-invalidation) for how to use this. +- No separate `getApiUrl` call - `apiOptions` produces the query key internally. +- Path params are passed as a typed `path` object. +- You use `useQuery` from TanStack Query directly instead of a custom wrapper. + +Similarly, `getApiQueryData` and `setApiQueryData` are deprecated. Use `queryClient.getQueryData` / `queryClient.setQueryData` directly with the query key from your options — the types are inferred automatically. ## Mutations (POST/PUT/DELETE requests) @@ -164,24 +308,22 @@ Mutations, unlike queries, do not fire automatically. Instead of `data`, it retu ### Quick start -Let’s say you have a button that creates a project with `POST /organizations//projects/`. You will need to define the response type (`CreateProjectResponse` in this example) as well as the shape of the object you will pass to the mutation function (`CreateProjectVariables` in this example). You can then use the `mutate` function to trigger the mutation: +Let's say you have a button that creates a project with `POST /organizations//projects/`. You will need to define the response type (`CreateProjectResponse` in this example) as well as the shape of the object you will pass to the mutation function (`CreateProjectVariables` in this example). You can then use the `mutate` function to trigger the mutation: ```tsx -import {fetchMutation, useMutation} from 'sentry/utils/queryClient'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {useMutation} from '@tanstack/react-query'; type CreateProjectResponse = {id: string; name: string}; type CreateProjectVariables = {name: string; orgSlug: string}; function Component() { - const {mutate} = useMutation< - CreateProjectResponse, - RequestError, - CreateProjectVariables - >({ - ...options, + const {mutate} = useMutation({ mutationFn: ({name, orgSlug}: CreateProjectVariables) => - fetchMutation({ - url: `/organizations/${orgSlug}/projects/`, + fetchMutation({ + url: getApiUrl('/organizations/$organizationIdOrSlug/projects/', { + path: {organizationIdOrSlug: organization.slug}, + }), method: 'POST', data: {name} }), @@ -202,45 +344,44 @@ function Component() { In some situations, displaying a loading state for every action can be cumbersome and make the experience feel bloated and slow. For interactions like these, it may make sense to immediately update the cache rather than wait for a response. -While there is no loading state when optimistically updating, errors still need to be handled. Errors should reset the UI to the previous state and display a message to notify the user that their action didn’t succeed. +While there is no loading state when optimistically updating, errors still need to be handled. Errors should reset the UI to the previous state and display a message to notify the user that their action didn't succeed. -For an example, let’s say that you want to update the project’s name: +For an example, let's say that you want to update the project's name: ```tsx // useFetchProject.tsx -export function makeFetchProjectQueryKey({id}): ApiQueryKey { - return [`/projects/${id}`]; -} +export const projectApiOptions = ({orgSlug, projectSlug}) => + apiOptions.as()('/projects/$organizationIdOrSlug/$projectIdOrSlug/', { + path: {organizationIdOrSlug: orgSlug, projectIdOrSlug: projectSlug}, + staleTime: Infinity, + }); // useUpdateProjectNameOptimistic.tsx -function useUpdateProjectNameOptimistic(incomingOptions: UpdateProjectOptions) { +function useUpdateProjectNameOptimistic({orgSlug}) { const queryClient = useQueryClient(); - const options: Options = { - ...incomingOptions, + return useMutation({ mutationFn: ({id, name}) => { - return fetchMutations({ - url: `/projects/${id}/`, + return fetchMutation({ + url: getApiUrl('/organizations/$organizationIdOrSlug/projects/$projectIdOrSlug/', { + path: {organizationIdOrSlug: orgSlug, projectIdOrSlug: id}, + }), method: 'PUT', data: {name}, }); }, onMutate: async variables => { + const projectQueryKey = projectApiOptions({orgSlug, projectSlug: variables.id}).queryKey // Cancel any ongoing queries so our cache changes aren't overridden - await queryClient.cancelQueries(makeFetchProjectQueryKey({id: variables.id})); + await queryClient.cancelQueries({ queryKey: projectQueryKey}); - const previousProject = queryClient.getQueryData( - makeFetchProjectQueryKey({id: variables.id}) - ); + const previousProject = queryClient.getQueryData(projectQueryKey); // Update the cache with the new value - setQueryData(queryClient, makeFetchProjectQueryKey({id: variables.id}), oldData => { - return oldData ? {...oldData, name: variables.name} : oldData; + queryClient.setQueryData(projectQueryKey, oldData => { + return oldData ? {...oldData, json: { ...oldData.json, name: variables.name}} : oldData; }); - // Call `onMutate` in case a consumer wants to use this handler - incomingOptions.onMutate?.(variables); - // Return previous data that can be used in the case of an error // This will be accessible as `context` in the onError handler return {previousProject}; @@ -251,21 +392,16 @@ function useUpdateProjectNameOptimistic(incomingOptions: UpdateProjectOptions) { // Reset to the previous value which we set in the return value of onMutate if (context) { queryClient.setQueryData( - makeFetchProjectQueryKey({id: variables.id}), + projectApiOptions({orgSlug, projectSlug: variables.id}).queryKey, context.previousProject ); } - - incomingOptions.onError?.(error, variables, context); }, - onSettled: (...params) => { + onSettled: (_resp, _error, variables) => { // To be safe, trigger a refetch afterwards to ensure data is correct - queryClient.invalidateQueries({queryKey: ['todos']}); - incomingOptions.onSettled?.(...params); + queryClient.invalidateQueries(projectApiOptions({orgSlug, projectSlug: variables.id})); }, - }; - - return useMutation(options); + }); } ```