Ever found yourself writing the same queryFn
over and over again in every
useQuery
call? Or getting tired of creating custom hooks for every single API
endpoint? I'll share a simple trick I've been using in my projects to make React
Query more efficient and DRY.
Usually, we write React Query like this:
// Users endpoint
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
return response.json();
},
});
// Products endpoint
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: async () => {
const response = await fetch('/api/products');
return response.json();
},
});
// Categories endpoint
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: async () => {
const response = await fetch('/api/categories');
return response.json();
},
});
See the problem? We're writing the same fetch logic repeatedly! 😩
React Query actually has a defaultOptions
feature that we can use to create a
global queryFn
. This means we don't need to write queryFn
in every
useQuery
anymore.
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
queryFn: async ({ queryKey, signal }) => {
const [context] = queryKey as [string];
// ... rest of logic
return await fetch(context);
},
},
},
});
}
To make this approach even better, we can define proper TypeScript types for our query keys using TanStack Query's module augmentation:
import '@tanstack/react-query';
type QueryKey = [
'users' | 'products' | 'categories' | 'dashboard' | 'orders',
...ReadonlyArray<unknown>,
];
declare module '@tanstack/react-query' {
interface Register {
queryKey: QueryKey;
}
}
This gives us:
The concept is beautifully simple:
queryKey
directly as the endpoint URLAfter the setup above, usage becomes incredibly simple:
// Get all users - TypeScript will validate 'users' is allowed
const { data: users } = useQuery({
queryKey: ['users'],
});
// Get all products
const { data: products } = useQuery({
queryKey: ['products'],
});
// Get specific user by ID
const { data: user } = useQuery({
queryKey: [`users/${userId}`], // Dynamic endpoints work too!
});
// Get dashboard data
const { data: dashboard } = useQuery({
queryKey: ['dashboard'],
});
No more repetitive queryFn
! 🎉
Here's a more robust implementation that handles parameters:
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
queryFn: async ({ queryKey, signal }) => {
const [context, params] = queryKey;
// Assume we have custom functions to handle these things
return await request(generateUrl(context, params), {
signal,
});
},
},
},
});
}
With corresponding TypeScript types:
import '@tanstack/react-query';
type QueryKey = [context: string, params?: ParamsType];
declare module '@tanstack/react-query' {
interface Register {
queryKey: QueryKey;
}
}
No unnecessary complexity. Query key = endpoint configuration, that's it! Perfect for straightforward GET requests.
No more writing async () => fetch()
repeatedly. All GET logic in one place.
TypeScript catches invalid query keys at compile time, preventing runtime errors.
All requests go through the same logic, making behavior predictable. Error handling, caching, business logic - all consistent.
Need a new endpoint? Just add it to the type definition and use the appropriate query key. Done!
IntelliSense and autocomplete make development faster and less error-prone.
In a production application, this looks like:
// Dashboard component
function Dashboard() {
const { data: stats } = useQuery({
queryKey: ['dashboard/stats', { period: '30d' }], // context with params
});
const { data: recentOrders } = useQuery({
queryKey: ['orders', { status: 'recent', limit: 10 }],
});
const { data: products } = useQuery({
queryKey: ['products'], // Simple endpoint without params
});
// Clean, type-safe, no repetitive queryFn! 🎉
}
// Good: With parameters
const { data } = useQuery({
queryKey: ['users', { page: 1, limit: 20 }],
});
// Also good: Simple endpoint
const { data } = useQuery({
queryKey: ['categories'],
});
type QueryKey = [
context: 'users' | 'products' | 'orders' | 'dashboard' | 'categories',
params?: Record<string, unknown>,
];
queryFn: async ({ queryKey, signal }) => {
const [context, params] = queryKey;
try {
return await request(generateUrl(context, params), {
signal,
});
} catch (error) {
throw new Error(`Failed to fetch ${context}: ${error.message}`);
}
};
This approach is perfect for:
Might be overkill for:
By leveraging React Query's default queryFn
with TypeScript's module
augmentation, we can:
Since React Query is designed primarily for GET requests, this approach aligns perfectly with its nature. I've used this in production applications, and honestly, it's a game changer!
Try implementing this in your project and feel the difference. You'll be amazed at how simple data fetching becomes.
Happy coding! 🚀
Have you tried this approach? Share your experience in the comments! Or maybe you have an even cooler variation?
new