Building a Consistent Data‑Fetching Layer in React with TanStack Query

A React data-fetching pattern using TanStack Query, custom hooks, and a small REST API DSL.
Fetching data in React can range from a simple fetch call inside a component to sophisticated RPC frameworks. In small apps, you might sprinkle useQuery calls throughout your components. As an app grows, you might extract those calls into custom hooks that wrap useQuery or useMutation and encapsulate query keys and options. If your backend exposes a strongly typed router (e.g., tRPC), you can even generate fully typed hooks and query key factories automatically – tRPC’s TanStack Query integration exposes factories for queryKeys, queryOptions and mutationOptions and encourages you to call useQuery(trpc.procedure.queryOptions(...)).
However, most REST APIs don’t provide a ready‑made router, and even when using tRPC you might want more control over network concerns like authentication headers, timeouts and optimistic updates. This article describes a pattern inspired by the @trpc/tanstack‑react‑query approach but tailored to a generic REST API. The core idea is to wrap Axios and TanStack Query in a small domain‑specific language (DSL) that centralizes endpoints, query key generation and network options. This yields a TRPC‑like developer experience without sacrificing flexibility – you still get to define your own types and adjust react‑query behaviour per endpoint.
Before diving into the pattern, some context helps. The approach described here comes from building an enterprise application used by an IT department to manage computer maintenance workflows and user support tickets. The system needs predictable, typed, and maintainable data-fetching behavior across dozens of screens—lists, dashboards, detail pages, wizards, and modal-driven flows. Axios happens to be the HTTP client in this project, but nothing in the architecture depends on it: any fetch layer—fetch, Ky, SuperAgent, custom wrappers—can slot in behind the same query/mutation factories. The goal isn’t to promote a specific library but to show how a small, TRPC-inspired DSL can standardize endpoints, stabilize query keys, and provide a consistent developer experience across a fast-growing React codebase.
Why not just call useQuery everywhere?
Beginners often sprinkle useQuery and useMutation calls directly inside components. This works for trivial apps, but it quickly becomes repetitive:
-
Query keys scattered everywhere. Query keys determine how TanStack Query caches responses – the key must uniquely identify the data. If you hand‑craft keys in dozens of components, mistakes creep in.
-
No shared defaults. TanStack Query offers many options (e.g.,
staleTime,placeholderData) that need to be tuned for each endpoint. Without a central place to set defaults, behaviour becomes inconsistent. -
Network concerns leak into UI code. In bigger apps you need to inject auth tokens, handle error codes, display toast notifications, upload files with progress and invalidate multiple queries. Doing that from every component quickly turns into boilerplate.
Custom hooks alleviate some of this by encapsulating a useQuery call and returning the result. They work well for simple queries but still leave you manually composing options and invalidation logic. A more scalable pattern is to generate query and mutation factories. That’s what the tRPC integration does: it exposes factories such as queryOptions, mutationOptions and queryKey, allowing you to pass the resulting objects into useQuery/useMutation. Those factories know the procedure path, input types and default options, and they produce consistent keys.
Our goal is to reproduce that experience for a REST API.
Overview of the pattern
The pattern is built around six pieces:
1. HTTP client with auth and guards
Instead of calling fetch directly, create an Axios instance that injects an Authorization header from your auth store and handles token expiry. Use interceptors to add a long timeout and log the user out on 401 or expired JWT responses. The client exposes a simple client.get/post/put/delete API that returns the response data.
import axios, { AxiosError } from "axios";
import { useAuthStore } from "@/stores";
export const client = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json", Accept: "application/json" },
});
client.interceptors.request.use((config) => {
config.timeout = 900_000;
const token = useAuthStore.getState().access_token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
client.interceptors.response.use(
(res) => res,
(err) => {
// logout on 401 or expired JWT
if (
err instanceof AxiosError &&
(err.response?.status === 401 ||
err.response?.data?.error === "jwt expired")
) {
useAuthStore.getState().logout();
}
throw err;
}
);
Isolating auth logic in the client keeps network concerns away from components. You can extend this with retry logic, cancellation or instrumentation later.
2. Centralized endpoint definitions
Next, put all of your API paths in a single endpoint.ts file. Each entry is a pure string or function that builds a path. Because components never concatenate strings themselves, it’s obvious where to modify routes, and TypeScript can help you avoid mismatched parameters.
import qs from "qs";
export const endpoint = {
getTickets: "/tickets",
getTicket: (id: number) => `/tickets/${id}`,
getTicketReport: (dimension: "priority" | "subject", range?: DateRange) => {
const query = qs.stringify(
{ dimension, range },
{ skipNulls: true, addQueryPrefix: true }
);
return `/tickets/reports${query}`;
},
// ...more endpoints
};
Having endpoints centrally defined reduces typos and makes it easy to inspect available routes. Because the functions return plain strings, they can be used both by the client and by other tools (e.g., for mocking).
3. Query and mutation factories
This is the heart of the pattern. Instead of writing custom hooks, we expose small factory functions that produce TanStack Query options. Inspired by tRPC’s integration, our factories expose three things:
-
endpoint: the path or path generator. -
queryKey(params): a stable key function to uniquely identify cached data. -
queryOptions(params, overrides?): builds an options object foruseQuery, including thequeryKey,queryFn, defaultstaleTimeand optional flags.
For mutations there is an analogous mutationOptions(params, overrides?) that selects the HTTP verb and returns a mutationFn. Upload and download helpers can also track progress and return a Blob when appropriate.
const DEFAULT_STALE_TIME = 2 * 60 * 5000;
function resolvePath<TParams>(path: Endpoint<TParams>, params: TParams) {
return typeof path === "function" ? path(params) : path;
}
function createQuery<TParams, TResult>({
path,
key,
enabled,
staleTime,
placeholderData,
}: QueryBuilder<TParams, TResult>) {
return {
endpoint: path,
queryKey: (params: TParams) => key(params),
queryOptions: (params: TParams, overrides = {}) => {
const resolved = resolvePath(path, params);
// query function uses Axios client and abort signal
const queryFn: QueryFunction<TResult> = async ({ signal }) =>
(await client.get<TResult>(resolved, { signal })).data;
return {
queryKey: key(params),
// skip the query if enabled() returns false.
// React Query’s skipToken is a type‑safe way to disable a query.
queryFn: enabled ? (enabled(params) ? queryFn : skipToken) : queryFn,
staleTime: staleTime ?? DEFAULT_STALE_TIME,
placeholderData,
...overrides,
} satisfies UseQueryOptions<TResult>;
},
};
}
function createMutation<TParams, TVariables = void, TResult = void>({
path,
method,
}: MutationBuilder<TParams>) {
return {
endpoint: path,
mutationOptions: (params: TParams, overrides = {}) => {
const resolved = resolvePath(path, params);
const mutationFn: MutationFunction<TResult, TVariables> = async (
variables
) => {
switch (method) {
case "put":
return (await client.put<TResult>(resolved, variables)).data;
case "patch":
return (await client.patch<TResult>(resolved, variables)).data;
case "delete":
return (await client.delete<TResult>(resolved)).data;
default:
return (await client.post<TResult>(resolved, variables)).data;
}
};
return {
mutationFn,
...overrides,
} satisfies UseMutationOptions<TResult, unknown, TVariables>;
},
};
}
To assemble a domain‑specific API, you call these helpers for every resource and operation. For example, to create queries and mutations for tickets:
import { endpoint } from "./endpoint";
import { createQuery, createMutation } from "./request";
export const tickets = {
list: createQuery<TicketFilters, Paginated<Ticket>>({
path: endpoint.getTickets,
key: (filters: TicketFilters) => ["tickets", filters],
// disable query when filters are empty
enabled: (f) => Boolean(f),
// keep previous page visible while loading the next page
placeholderData: keepPreviousData,
}),
detail: createQuery<number, Ticket>({
path: endpoint.getTicket,
key: (id: number) => ["ticket", id],
}),
create: createMutation<void, AddTicketPayload, Ticket>({
path: endpoint.getTickets,
method: "post",
}),
update: createMutation<number, UpdateTicketPayload, Ticket>({
path: endpoint.getTicket,
method: "patch",
}),
delete: createMutation<number>({
path: endpoint.getTicket,
method: "delete",
}),
};
Notice how the list query uses placeholderData: keepPreviousData so that when you change pages or filters, the previous list remains visible while new data is loading. In React Query v5, placeholderData combined with the special keepPreviousData value keeps the prior data in the cache and exposes an isPlaceholderData flag. That flag allows you to show a subtle loading indicator while preserving the old list.
For optional parameters, we use the enabled callback to return false when parameters are missing. Internally, the query factory will return React Query’s skipToken, which disables the query in a type‑safe way. The docs note that skipToken disables the query and is a good alternative to enabled: false.
4. Query client provider
Wrap your application with a single QueryClientProvider so that TanStack Query can cache data across the component tree. It’s important to create the QueryClient only once; re‑creating it on every render resets the cache.
Here’s how we set up our providers:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false }, // optional, adapt
},
});
export function TanStackQueryProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
Wrap this provider around your router and any other global providers in main.tsx.
5. Component usage patterns
Using the DSL in components is straightforward:
Detail pages
const { id } = useParams();
// the type of data will be correctly infered to: Ticket | undefined
const { data, isPending } = useQuery(api.tickets.detail.queryOptions(id));
if (!data && isPending) return <Skeleton />;
return <TicketDetails ticket={data} />;
Lists with filtering and pagination
const filters = useMemo(
() => ({
search,
page,
pageSize,
}),
[search, page, pageSize]
);
const { data, isPending, isPlaceholderData } = useQuery(
api.tickets.list.queryOptions(filters)
);
return (
<>
{isPending && !isPlaceholderData ? <Spinner /> : null}
<TicketTable tickets={data?.items ?? []} />
<Pagination
page={page}
total={data?.meta.totalPages ?? 1}
onChange={(p) => setPage(p)}
/>
</>
);
Because placeholderData: keepPreviousData is configured in the API, changing filters doesn’t blank the table; you still see the old page while the new page loads. You can use isPlaceholderData to dim the table or show a subtle loading bar.
Mutations
For create/update/delete operations, call useMutation with the mutation options produced by the factory. Optionally invalidate queries on success:
const queryClient = useQueryClient();
const mutation = useMutation(
api.tickets.create.mutationOptions(undefined, {
onSuccess: async () => {
// invalidate the list and any related dashboard queries
await Promise.all([
queryClient.invalidateQueries({
queryKey: api.tickets.list.queryKey(),
}),
queryClient.invalidateQueries({
queryKey: api.dashboard.overview.queryKey(),
}),
]);
toast.success("Ticket created");
},
})
);
// trigger mutation
mutation.mutate(formData);
Thanks to the factory, you don’t have to remember the HTTP verb or endpoint; the mutation function is already wired up. You can even pass additional React Query options (e.g., onMutate, onError) through the overrides argument.
6. Putting it all together
To use this pattern in a fresh project:
-
Install dependencies – you need
@tanstack/react-query,axiosand optionallyqsfor query string building. -
Wrap your app in a
QueryClientProvider. Create the client once and pass it to the provider -
Create an Axios client with base URL and interceptors for auth and error handling.
-
Centralize endpoints in a file of pure string builders.
-
Write query/mutation factories. Provide defaults like
staleTimeandplaceholderData, and returnskipTokenwhen queries should be disabled. -
Group your API by domain – e.g.
api.tickets.listorapi.users.detail– and export factories for each operation. -
Use
useQueryanduseMutationin your components by passing the options produced by your API factories. Invalidate queries viaqueryClient.invalidateQueriesusing the query keys from the API.
Advantages and trade‑offs
Advantages
-
Centralizing the
queryKeyand options makes it impossible to accidentally cache different data under the same key. It also ensures that defaults likestaleTimeorretryare applied consistently. -
Because the factories return plain TanStack Query options, you can pass them into any of the query hooks (
useQuery,useSuspenseQuery,useInfiniteQuery) or utilities likequeryClient.prefetchQuery. The pattern doesn’t hide React Query; it simply reduces boilerplate. -
TRPC’s integration advocates using factories for
queryOptions,mutationOptionsandqueryKey. Our pattern mirrors that API: you writeuseQuery(api.tickets.detail.queryOptions(id))instead ofuseQuery(['tickets', id], () => client.get(...)). The difference is that you define your own types instead of relying on tRPC to infer them. This manual typing is more work up front but gives you full control over response shapes and allows you to use any backend, not just tRPC.
Trade-offs
-
The biggest downside compared to tRPC is that your factories can’t infer input/output types from a router. You must define
TParamsandTResultfor each query and mutation. However, the pattern still centralizes those definitions, making them easy to update. -
Although the pattern centralizes endpoints and options, you still need to follow best practices: use stable query keys, invalidate queries appropriately and avoid creating multiple
QueryClientinstances. React Query’s docs emphasise that queries are disabled by default when usingskipToken– but callingrefetch()on askipTokenquery doesn’t work. You need to keep such edge cases in mind.
Conclusion
By wrapping Axios and TanStack Query in a small DSL, you can reproduce the ergonomic experience of tRPC’s React Query integration while keeping full control over network behaviour and typing. The pattern centralizes endpoints, generates stable query keys and hides the boilerplate of constructing queryFn objects. You get the flexibility to adjust staleTime, enabled, placeholderData and mutation logic per endpoint, and you can extend the factories with uploads, downloads or custom headers. With a bit of upfront setup, your components become lean and focused on UI logic, and your data layer stays consistent and easy to maintain.