Skip to content

With TanStack Query

expect-status pairs naturally with TanStack Query — it returns a promise that resolves to the typed body or throws, which is exactly what queryFn and mutationFn expect.

import { useQuery } from "@tanstack/react-query";
import { expectStatus } from "expect-status";
type OrgResponse =
| { status: 200; body: { id: string; name: string } }
| { status: 404; body: { message: string } };
function useOrganisation(id: string) {
return useQuery({
queryKey: ["org", id],
queryFn: () =>
expectStatus(200, client.getOrganisation({ params: { id } })),
});
}
// data is typed as { id: string; name: string }
// error is the thrown ExpectStatusError
const { data, error, isLoading } = useOrganisation("org_1");

No wrappers or adapters needed — expectStatus returns a promise that resolves on success and throws on error, which maps directly to TanStack Query’s data / error states.

import { useMutation } from "@tanstack/react-query";
import { expectStatus } from "expect-status";
function useCreateOrganisation() {
return useMutation({
mutationFn: (data: CreateOrgInput) =>
expectStatus(201, client.createOrganisation({ body: data }), {
409: (body) => redirect(`/org/${body.organisationId}`),
422: "Invalid organisation details.",
}),
onSuccess: (org) => {
// org is typed as the 201 body
queryClient.invalidateQueries({ queryKey: ["orgs"] });
},
onError: (err) => {
toast.error(err.message);
},
});
}

For mutations where you want structured results instead of exceptions:

function useCreateOrganisation() {
return useMutation({
mutationFn: async (data: CreateOrgInput) => {
const result = await expectStatus(
201,
client.createOrganisation({ body: data }),
{ throws: false },
);
if (!result.ok) {
return { error: result.error.message };
}
return { data: result.data };
},
});
}
import { createExpectStatus } from "expect-status";
const expectStatus = createExpectStatus({
fallbackMessage: "Something went wrong.",
defaults: {
401: "Please sign in.",
403: "You do not have permission.",
"5xx": "Service unavailable. Please try again.",
},
onError: (err, response) => {
Sentry.captureException(err, {
extra: { status: response.status },
});
},
});
function useItems() {
return useQuery({
queryKey: ["items"],
queryFn: () => expectStatus(200, client.listItems()),
});
}

Default messages and observability hooks apply automatically to every query — no per-hook configuration needed.

function useUpdateItem() {
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateItemInput }) =>
expectStatus(200, client.updateItem({ params: { id }, body: data }), {
409: "Item was modified by someone else. Please refresh.",
}),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: ["item", id] });
const previous = queryClient.getQueryData(["item", id]);
queryClient.setQueryData(["item", id], (old) => ({ ...old, ...data }));
return { previous };
},
onError: (err, { id }, context) => {
queryClient.setQueryData(["item", id], context?.previous);
toast.error(err.message);
},
onSettled: (_, __, { id }) => {
queryClient.invalidateQueries({ queryKey: ["item", id] });
},
});
}
import { useSuspenseQuery } from "@tanstack/react-query";
function useOrganisation(id: string) {
return useSuspenseQuery({
queryKey: ["org", id],
queryFn: () =>
expectStatus(200, client.getOrganisation({ params: { id } }), {
404: "Organisation not found.",
}),
});
}
// In a component wrapped with <Suspense> and <ErrorBoundary>:
// - Loading state handled by Suspense
// - Error state handled by ErrorBoundary
// - data is typed as the 200 body (no undefined)

Use recover for queries that should gracefully degrade:

function useOrganisation(id: string) {
return useQuery({
queryKey: ["org", id],
queryFn: () =>
expectStatus(200, client.getOrganisation({ params: { id } }), {
recover: () => null, // return null instead of throwing
}),
});
}
// data is typed as Organisation | null