Web Development
Type-Safe Data Fetching with React Query
React Query handles caching, retries, and stale-ness. The part it leaves to you — types — is where most teams leak bugs. Here is a clean pattern.

React Query solves the hard parts of client-side data fetching — caching, deduplication, retries, background refresh — and then hands you an untyped any to work with. The library is not at fault; it cannot know the shape of your API. But that means the type boundary is your responsibility, and it is where most React Query codebases quietly rot.
Here is a pattern that keeps the boundary tight.
Type the query, not just the hook
The common mistake is typing the hook's return value. The better move is typing the query function and letting inference do the rest.
// Define the domain type once
export interface Article {
id: string;
title: string;
publishedAt: string;
readingTime: number;
}
// Type the query function explicitly
async function fetchArticle(id: string): Promise<Article> {
const res = await fetch(`/api/articles/${id}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json() as Promise<Article>;
}Now the query hook infers everything:
function useArticle(id: string) {
return useQuery({
queryKey: ["article", id],
queryFn: () => fetchArticle(id),
});
}
// useArticle(id).data is Article | undefined — correctly typedNotice we never annotate the hook. The type flows from the query function. If the API shape changes and you update Article, every consumer updates with it.
Validate at the boundary, trust everywhere else
The cast as Promise<Article> is a promise, not a guarantee. If the server returns something else, your types lie. For anything beyond a trusted internal API, validate at the edge:
function isArticle(x: unknown): x is Article {
return (
typeof x === "object" && x !== null &&
"id" in x && "title" in x &&
typeof (x as Article).id === "string"
);
}
async function fetchArticle(id: string): Promise<Article> {
const res = await fetch(`/api/articles/${id}`);
const data: unknown = await res.json();
if (!isArticle(data)) throw new Error("Malformed article");
return data;
}For larger schemas, reach for a validation library — but the principle holds: validate once, at the boundary, then let TypeScript carry the certainty inward.
Key design: keep query keys structured
Query keys are the cache address. Treat them like a typed API:
const articleKeys = {
all: ["articles"] as const,
detail: (id: string) => ["article", id] as const,
list: (filter: ArticleFilter) => ["articles", "list", filter] as const,
};Now invalidation is typo-proof:
queryClient.invalidateQueries({ queryKey: articleKeys.detail(id) });Takeaways
- Type the query function; let the hook infer.
- Validate untrusted data at the boundary, once.
- Structure query keys as a small typed API.
React Query gives you caching for free. The types are the part you actually own — own them deliberately.