Network Requests

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.

apiOptions is a type-safe factory for creating TanStack Query queryOptions that hit Sentry API endpoints. It replaces the legacy useApiQuery hook and is the recommended way to fetch data in this codebase.

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:

  • useQuery(apiOptions.as<T>()(...))
  • useQueries({ queries: [apiOptions.as<A>()(...), apiOptions.as<B>()(...)]})
  • queryClient.fetchQuery(apiOptions.as<T>()(...))

A custom hook like useApiQuery can only be used inside React components. By building on queryOptions instead, we get one abstraction that works everywhere.

Copied
import { useQuery } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

function IssueDetail({ organization, issueId }) {
  const { data, isPending, isError } = useQuery(
    apiOptions.as<Group>()(
      "/organizations/$organizationIdOrSlug/issues/$issueId/",
      {
        path: { organizationIdOrSlug: organization.slug, issueId },
        staleTime: 30_000,
      },
    ),
  );

  // data is Group | undefined
}

The .as<T>() call provides the response type (until we can infer it from the endpoint itself).

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 to disable the query.

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.

To conditionally disable a query, pass skipToken as the path value:

Copied
import { skipToken, useQuery } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

function ProjectDetail({ organization, projectSlug }) {
  const { data } = useQuery(
    apiOptions.as<Project>()(
      "/organizations/$organizationIdOrSlug/projects/$projectIdOrSlug/",
      {
        path: projectSlug
          ? {
              organizationIdOrSlug: organization.slug,
              projectIdOrSlug: projectSlug,
            }
          : skipToken,
        staleTime: 30_000,
      },
    ),
  );
}

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.

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:

Copied
import { skipToken } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

export function sentryAppApiOptions({
  appSlug,
}: {
  appSlug: string | null;
}) {
  return apiOptions.as<SentryApp>()("/sentry-apps/$sentryAppIdOrSlug/", {
    path: appSlug ? { sentryAppIdOrSlug: appSlug } : skipToken,
    staleTime: 0,
  });
}

Now any consumer can use it with useQuery, prefetchQuery, or anything else:

Copied
// In a component
const { data } = useQuery(sentryAppApiOptions({ appSlug: "my-app" }));

// In a loader or prefetch
queryClient.prefetchQuery(sentryAppApiOptions({ appSlug: "my-app" }));

If you need to add shared options like retry: false on top of apiOptions, wrap it in queryOptions from TanStack Query:

Copied
import { queryOptions, skipToken } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

export function projectTeamsApiOptions({ orgSlug, projectSlug, cursor }) {
  return queryOptions({
    ...apiOptions.as<Team[]>()(
      "/projects/$organizationIdOrSlug/$projectIdOrSlug/teams/",
      {
        path:
          orgSlug && projectSlug
            ? {
                organizationIdOrSlug: orgSlug,
                projectIdOrSlug: projectSlug,
              }
            : skipToken,
        query: { cursor },
        staleTime: 0,
      },
    ),
    retry: false,
  });
}

This pattern lets you share options like retry, gcTime, or refetchInterval without losing type safety.

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:

Copied
const { data } = useQuery({
  ...projectTeamsApiOptions({ orgSlug, projectSlug }),
  select: (response) => response.json.filter((team) => team.isMember),
  enabled: hasPermission,
});

Note that select receives the raw ApiResponse<T> (see How the cache stores data below), so you access the body via response.json.

Internally, API responses are stored in the query cache as ApiResponse<T>:

Copied
type ApiResponse<T> = {
  headers: {
    Link?: string;
    "X-Hits"?: number;
    "X-Max-Hits"?: number;
  };
  json: T;
};

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.

To access response headers (e.g. Link for pagination, X-Hits for total counts), override select with selectJsonWithHeaders:

Copied
import { 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;
const totalHits = data?.headers["X-Hits"]; // number | undefined
const maxHits = data?.headers["X-Max-Hits"]; // number | undefined

You can also provide your own select function. It receives the raw ApiResponse<T> and can return whatever shape you need:

Copied
const { data } = useQuery({
  ...apiOptions.as<Item[]>()(
    "/organizations/$organizationIdOrSlug/items/",
    {
      path: { organizationIdOrSlug: organization.slug },
      staleTime: 30_000,
    },
  ),
  select: (response) => response.json.filter((item) => item.isActive),
});

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:

Copied
const opts = sentryAppApiOptions({ appSlug: "my-app" });

// TypeScript knows this is ApiResponse<SentryApp> | undefined
const cached = queryClient.getQueryData(opts.queryKey);

// TypeScript enforces the correct type for the updater
queryClient.setQueryData(opts.queryKey, (prev) => {
  // prev is ApiResponse<SentryApp> | undefined
  // ...
});

Any TanStack Query API that gives you direct access to cached data — rather than going through select — will expose the raw ApiResponse<T> structure:

  • queryClient.getQueryData(key) — returns ApiResponse<T> | undefined
  • queryClient.setQueryData(key, updater) — the updater receives and must return ApiResponse<T>
  • retry function — when using a function form, the error is from the raw fetch
  • predicate in invalidateQueries / removeQueries — the query.state.data is ApiResponse<T>
  • initialData — must be ApiResponse<T>, not just T
  • placeholderData — receives ApiResponse<T> from the previous query

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.

Because apiOptions produces a queryKey, you can use it for cache invalidation too. Pass the .queryKey from your abstraction to invalidateQueries:

Copied
const opts = sentryAppApiOptions({ appSlug: "my-app" });

// Invalidate this specific query
queryClient.invalidateQueries({ queryKey: opts.queryKey });

Since the return value of apiOptions matches the QueryFilter that needs to be passed invalidateQueries, you can also pass it directly:

Copied
const opts = sentryAppApiOptions({ appSlug: "my-app" });
queryClient.invalidateQueries(opts);

For paginated endpoints that use cursor-based pagination, use apiOptions.asInfinite:

Copied
import { useInfiniteQuery } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
  apiOptions.asInfinite<Item[]>()(
    "/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.

useApiQuery is deprecated. To migrate:

Copied
// Before
const { data, isPending } = useApiQuery<Project[]>(
  [
    getApiUrl("/organizations/$organizationIdOrSlug/projects/", {
      path: { organizationIdOrSlug: organization.slug },
      query: { cursor: "1" },
    }),
  ],
  { staleTime: 0 },
);

// After
const { data, isPending } = useQuery(
  apiOptions.as<Project[]>()(
    "/organizations/$organizationIdOrSlug/projects/",
    {
      path: { organizationIdOrSlug: organization.slug },
      query: { cursor: "1" },
      staleTime: 0,
    },
  ),
);

Key differences:

  • 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, unlike queries, do not fire automatically. Instead of data, it returns a mutate function which accepts the dynamic variables necessary to complete the mutation. Also note that unlike useApiQuery where the query function was created automatically, you must supply your own mutation function.

Let's say you have a button that creates a project with POST /organizations/<org>/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:

Copied
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({
    mutationFn: ({ name, orgSlug }: CreateProjectVariables) =>
      fetchMutation<CreateProjectResponse>({
        url: getApiUrl("/organizations/$organizationIdOrSlug/projects/", {
          path: { organizationIdOrSlug: organization.slug },
        }),
        method: "POST",
        data: { name },
      }),
    onSuccess: (response) => {
      addSuccessMessage(`Successfully created project ${response.name}`);
    },
  });

  return (
    <button
      onClick={() => mutate({ name: "My new project", orgSlug: "my-org" })}
    >
      Create project
    </button>
  );
}

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.

For an example, let's say that you want to update the project's name:

Copied
// useFetchProject.tsx
export const projectApiOptions = ({ orgSlug, projectSlug }) =>
  apiOptions.as<Project>()(
    "/projects/$organizationIdOrSlug/$projectIdOrSlug/",
    {
      path: {
        organizationIdOrSlug: orgSlug,
        projectIdOrSlug: projectSlug,
      },
      staleTime: Infinity,
    },
  );

// useUpdateProjectNameOptimistic.tsx
function useUpdateProjectNameOptimistic({ orgSlug }) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, name }) => {
      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({ queryKey: projectQueryKey });

      const previousProject = queryClient.getQueryData(projectQueryKey);

      // Update the cache with the new value
      queryClient.setQueryData(projectQueryKey, (oldData) => {
        return oldData
          ? { ...oldData, json: { ...oldData.json, name: variables.name } }
          : oldData;
      });

      // 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 };
    },
    onError: (error, variables, context) => {
      addErrorMessage(t("Failed to update project name."));

      // Reset to the previous value which we set in the return value of onMutate
      if (context) {
        queryClient.setQueryData(
          projectApiOptions({ orgSlug, projectSlug: variables.id })
            .queryKey,
          context.previousProject,
        );
      }
    },
    onSettled: (_resp, _error, variables) => {
      // To be safe, trigger a refetch afterwards to ensure data is correct
      queryClient.invalidateQueries(
        projectApiOptions({ orgSlug, projectSlug: variables.id }),
      );
    },
  });
}

We use MockApiClient to mock API responses. This is a globally-available class in tests that reimplements Client in the test environment. You can read up on the logic here.

Copied
import {render, screen} from 'sentry-test/reactTestingLibrary';

describe('useFetchSomeData', () => {
  it('should fetch', () => {
    const request = MockApiClient.addMockResponse(...);

    render(<MyComponentThatFetches />);

    expect(request).toHaveBeenCalled();
  });
});

Copied
import {makeTestQueryClient} from 'sentry-test/queryClient';
import {reactHooks} from 'sentry-test/reactTestingLibrary';
import {QueryClientProvider} from 'sentry/utils/queryClient';

function wrapper({children}: {children?: ReactNode}) {
  return (
    <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>
  );
}

describe('useFetchSomeData', () => {
  it('should fetch', () => {
    const request = MockApiClient.addMockResponse(...);

    const {result, waitFor} = reactHooks.renderHook(useFetchSomeData, {
      wrapper,
      initialProps: {organization},
    });

    expect(request).toHaveBeenCalled();
  });
});

Copied
// Mock fetching projects
MockApiClient.addMockResponse({
  url: "/projects/",
  body: [{ id: 1, name: "my project" }],
});

// Mock creating a project
MockApiClient.addMockResponse({
  url: "/projects/",
  method: "POST",
  body: { id: 1, name: "my project" },
});

// More complex matching logic:
// Matches POST /projects/?param=1 with {name: 'other'} in body
MockApiClient.addMockResponse({
  url: "/projects/",
  method: "POST",
  body: { id: 2, name: "other" },
  match: [
    MockApiClient.matchQuery({ param: "1" }),
    MockApiClient.matchData({ name: "other" }),
  ],
});

Copied
MockApiClient.addMockResponse({
  url: "/projects/",
  body: {
    detail: "Internal Error",
  },
  statusCode: 500,
});

Always, always, always await your assertions when a network request is involved! Although the data is readily available (since we provide mocks), it is still an async process which takes multiple render cycles. So be sure to use findBy queries when necessary.

When testing a component with a mutation, you often want to refetch data after the mutation has completed. Since the data has changed, you will need to update your mocks before the refetch occurs in order to create a test that matches reality. Otherwise you could have perfectly good logic which updates the cache, but the refetch will use the original mocked data and ruin the test. To prevent that from happening, see this example:

Copied
it('adds a project to the list', function () {
  MockApiClient.addMockResponse({
    url: '/projects/',
    body: [],
  });
  const createProject = mockApiClient.addMockResponse({
    url: '/projects/',
    method: 'POST',
    body: {id: 1, name: 'My Project'},
  });

  render(<ProjectList />);
  expect(screen.getByText('My Project')).not.toBeInTheDocument();

  await userEvent.click(screen.getByRole('button', {name: 'Create Project'}));
  // Override the previous response with the new data
  MockApiClient.addMockResponse({
    url: '/projects/',
    body: [{id: 1, name: 'My Project'}],
  });
  await waitFor(() => expect(createProject).toHaveBeenCalledWith({name: 'My Project'}));
  await screen.findByText('My Project').toBeInTheDocument();
});
Was this helpful?
Help improve this content
Our documentation is open source and available on GitHub. Your contributions are welcome, whether fixing a typo (drat!) or suggesting an update ("yeah, this would be better").