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.
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
}
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:
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,
},
),
);
}
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:
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,
});
}
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:
// In a component
const { data } = useQuery(sentryAppApiOptions({ appSlug: "my-app" }));
// In a loader or prefetch
queryClient.prefetchQuery(sentryAppApiOptions({ appSlug: "my-app" }));
// 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:
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,
});
}
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:
const { data } = useQuery({
...projectTeamsApiOptions({ orgSlug, projectSlug }),
select: (response) => response.json.filter((team) => team.isMember),
enabled: hasPermission,
});
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>:
type ApiResponse<T> = {
headers: {
Link?: string;
"X-Hits"?: number;
"X-Max-Hits"?: number;
};
json: T;
};
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:
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
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:
const { data } = useQuery({
...apiOptions.as<Item[]>()(
"/organizations/$organizationIdOrSlug/items/",
{
path: { organizationIdOrSlug: organization.slug },
staleTime: 30_000,
},
),
select: (response) => response.json.filter((item) => item.isActive),
});
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:
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
// ...
});
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)— returnsApiResponse<T> | undefinedqueryClient.setQueryData(key, updater)— the updater receives and must returnApiResponse<T>retryfunction — when using a function form, theerroris from the raw fetchpredicateininvalidateQueries/removeQueries— thequery.state.dataisApiResponse<T>initialData— must beApiResponse<T>, not justTplaceholderData— receivesApiResponse<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:
const opts = sentryAppApiOptions({ appSlug: "my-app" });
// Invalidate this specific query
queryClient.invalidateQueries({ queryKey: opts.queryKey });
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:
const opts = sentryAppApiOptions({ appSlug: "my-app" });
queryClient.invalidateQueries(opts);
const opts = sentryAppApiOptions({ appSlug: "my-app" });
queryClient.invalidateQueries(opts);
For paginated endpoints that use cursor-based pagination, use apiOptions.asInfinite:
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,
},
),
);
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:
// 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,
},
),
);
// 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
getApiUrlcall -apiOptionsproduces the query key internally. - Path params are passed as a typed
pathobject. - You use
useQueryfrom 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:
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>
);
}
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:
// 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 }),
);
},
});
}
// 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.
import {render, screen} from 'sentry-test/reactTestingLibrary';
describe('useFetchSomeData', () => {
it('should fetch', () => {
const request = MockApiClient.addMockResponse(...);
render(<MyComponentThatFetches />);
expect(request).toHaveBeenCalled();
});
});
import {render, screen} from 'sentry-test/reactTestingLibrary';
describe('useFetchSomeData', () => {
it('should fetch', () => {
const request = MockApiClient.addMockResponse(...);
render(<MyComponentThatFetches />);
expect(request).toHaveBeenCalled();
});
});
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();
});
});
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();
});
});
// 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" }),
],
});
// 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" }),
],
});
MockApiClient.addMockResponse({
url: "/projects/",
body: {
detail: "Internal Error",
},
statusCode: 500,
});
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:
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();
});
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();
});
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").