Before getting started ๐
This pattern works in both React and React Native.
React Query offers powerful data-fetching capabilities, but repeating the same queries across multiple pages or components creates lengthy code that is hard to maintain.
Separating query logic into custom hooks can improve both reuse and readability.
This post explains how to use React Query efficiently by
abstracting it into custom hooks
.
Using the JSONPlaceholder API, I will show a structure that can be applied directly in real projects.
Reviewing the basics: A useQuery example ๐
First, let's revisit the basic structure of using useQuery directly.
components/UserList.tsx const fetchUsers = async () => {
const res = await axios. get ( 'https://jsonplaceholder.typicode.com/users' );
return res.data;
};
function UserList () {
const { data , isLoading , error } = useQuery ({
queryKey: [ 'users' ],
queryFn: fetchUsers,
});
if (isLoading) return < div >๋ก๋ฉ ์ค...</ div >;
if (error) return < div >์๋ฌ ๋ฐ์!</ div >;
return < div > { data. map (( user ) => user.name) } </ div >;
}
This code is fine in one or two components, but using it in many places creates substantial duplication.
Abstracting queries into a custom hook ๐
Now let's turn this pattern into a custom hook.
For example, create
useUsers.ts with the following structure.
hooks/useUsers.ts import { useQuery } from '@tanstack/react-query' ;
import axios from 'axios' ;
const fetchUsers = async () => {
const res = await axios. get ( 'https://jsonplaceholder.typicode.com/users' );
return res.data;
};
export function useUsers () {
return useQuery ({
queryKey: [ 'users' ],
queryFn: fetchUsers,
});
}
Components can now use it as follows.
components/UserList.tsx import { useUsers } from '../hooks/useUsers' ;
export function UserList () {
const { data , isLoading , error } = useUsers ();
if (isLoading) return < div >๋ก๋ฉ ์ค...</ div >;
if (error) return < div >์๋ฌ ๋ฐ์!</ div >;
return < div > { data. map (( user ) => user.name) } </ div >;
}
Creating a custom hook with parameters ๐
The next hook, useUserPosts(userId), fetches posts belonging to a particular user.
hooks/useUserPosts.ts export function useUserPosts ( userId : number ) {
return useQuery ({
queryKey: [ 'user' , userId, 'posts' ],
queryFn : async () => {
const res = await axios. get ( `https://jsonplaceholder.typicode.com/users/${ userId }/posts` );
return res.data;
},
enabled: !! userId,
});
}
The enabled: !!userId option prevents the query from running when userId is undefined.
Accepting query options from the caller ๐
To allow flexible configuration, accept an options argument as follows.
hooks/useUsers.ts export function useUsers ( options ?: UseQueryOptions ) {
return useQuery ({
queryKey: [ 'users' ],
queryFn: fetchUsers,
... options,
});
}
The caller can then freely configure staleTime, onSuccess, onError, and other options.
Ensuring type safety ๐
Define the type of the fetched result to ensure type safety.
hooks/useUserPosts.ts interface FetchUserPostsResponse {
id : number ;
title : string ;
body : string ;
}
export function useUserPosts ( userId : number , options ?: UseQueryOptions < FetchUserPostsResponse , Error >) {
return useQuery ({
queryKey: [ 'user' , userId, 'posts' ],
queryFn : async () => {
const res = await axios. get ( `https://jsonplaceholder.typicode.com/users/${ userId }/posts` );
return res.data;
},
... options,
});
}
React Query provides powerful features, but duplication is easy to introduce without a custom-hook abstraction.
Moving repeated
queryKey,
queryFn, and related logic into hooks enables reuse across pages and simplifies testing and maintenance.
Well-designed custom hooks can improve code quality and consistency across a team.
I recommend making a
hook-based structure
your default approach when using React Query.