React Query is honestly one of the best things that's happened to frontend devs. If you've ever felt the pain of managing loading states, caching, refetching, pagination, or syncing server data with your UI… this thing just saves your soul.
So What exactly is React Query?
React Query is a data-fetching library that helps you manage server state or you can say managing fetch, cache, and update data in your React apps without managing all the state manually.
Why React Query is the best
- Automatic Caching: You hit an API once, and it remembers the result.
- Auto Refetching: Keeps your data fresh without manual effort.
- Built-in loading & error states: No more spaghetti isLoading logic.
- Mutations: Super clean way to do POST/PUT/DELETE requests.
- Devtools: Easily inspect and debug queries and mutations.
BUT…
Once your app grows, your React Query setup can get messy real quick like 30 different query keys, functions all over the place, no organization, types all mismatched.
Let me show you a better way. A scalable way. A way that won't make you hate yourself six months from now.
Step 1: Organize Your Query Keys
Most people start by writing things like:
useQuery(['posts'], fetchPosts)
useQuery(['post', id], fetchPostById)
Looks simple, until you end up with a million of these and have no idea what keys you've already used.
Instead, create a central file like query-keys.ts and define your keys properly.
Let's say we're building a simple Todo App instead of properties:
// query-keys.ts
export const QueryKeys = {
TODO: {
GET_ALL: ['TODO_GET_ALL'],
GET_BY_ID: (id: string) => ['TODO_GET_BY_ID', id],
CREATE: ['TODO_CREATE'],
UPDATE: ['TODO_UPDATE'],
DELETE: ['TODO_DELETE'],
},
USER: {
PROFILE: ['USER_PROFILE'],
},
};
Now you always use QueryKeys.TODO.GET_ALL
or QueryKeys.TODO.GET_BY_ID(id)
consistently.
Step 2: Create a query-options Folder
Inside your src/ folder (or root, up to you), create a query-options/ directory. This is where all your fetch logic lives.
Then create files per domain so for example todo.ts.
Example: query-options/todo.ts
We'll use a basic /api/todos structure and walk through the common operations.
Get all todo
// Types
type Todo = {
id: string;
title: string;
completed: boolean;
};
export const getAllTodosOptions = (): QueryOptions<Todo[]> => {
return {
queryKey: QueryKeys.TODO.GET_ALL,
queryFn: async () => {
const { data } = await axios.get<Todo[]>("/api/todos");
return data;
},
};
};
Create a todo
type CreateTodoInput = {
title: string;
};
export const createTodoOptions = (): MutationOptions<
Todo, // success response from api
AxiosError, // error type from axios can be anything you want
CreateTodoInput // input type which you send to backend
> => {
const queryClient = useQueryClient();
return {
mutationFn: async (values) => {
const { data } = await apiClient.post<Todo>("/api/todos", values);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: QueryKeys.TODO.GET_ALL,
});
},
};
};
Update a todo
type UpdateTodoInput = {
id: string;
title: string;
completed: boolean;
};
export const updateTodoOptions = (): MutationOptions<
Todo, // success response
AxiosError, // error type
UpdateTodoInput // input type
> => {
return {
mutationFn: async ({ id, ...rest }) => {
const { data } = await apiClient.patch<Todo>(`/api/todos/${id}`, rest);
return data;
},
};
};
Usage in Your Components
Instead of creating custom hooks like useGetTodos, I like to just use it directly in the component keeps everything close and readable.
import { useQuery, useMutation } from "@tanstack/react-query";
import {
getAllTodosOptions,
createTodoOptions,
} from "@/query-options/todo";
export default function TodoList() {
const { data: todos, isLoading } = useQuery(getAllTodosOptions());
const { mutate: createTodo, isPending } = useMutation(createTodoOptions());
return (
<>
{isLoading ? (
<p>Loading...</p>
) : (
todos?.map((todo) => <div key={todo.id}>{todo.title}</div>)
)}
<button
disabled={isPending}
onClick={() => createTodo({ title: "New Todo" })}
>
Add Todo
</button>
</>
);
}
Bonus: Extend Query Options Inside Components
Let's say you wanna pass some custom stuff only in one place like inital data or pagination limit:
const { data, isLoading } = useQuery({
...getAllTodosOptions(),
initialData: [],
staleTime: 1000 * 60
});
This is why I love this pattern you keep flexibility in the component but the logic stays organized in one place.
Conclusion
React Query is awesome, but only if you keep it tidy. Once your app grows, you need:
- Centralized query keys
- Domain-based query-options
- Avoid unnecessary custom hooks (unless you need them)
- Type everything your future self will thank you
Hope this helps you write more scalable and clean React Query code.
And if your project's already a mess… it's never too late to clean it up 😉
Connect with Me
If you enjoyed this post and want to stay in the loop with similar content, feel free to follow and connect with me across the web:
- Twitter: Follow me on Twitter for bite-sized tips, updates, and thoughts on tech.
- Medium: Check out my articles on Medium where I share tutorials, insights, and deep dives.
- Email: Got questions, ideas, or just want to say hi? Drop me a line at codezera3@gmail.com.
Your support means a lot, and I'd love to connect, collaborate, or just geek out over cool projects. Looking forward to hearing from you!
Top comments (0)