DEV Community

Cover image for The Cleanest Way to Use React Query
CodeZera
CodeZera

Posted on

The Cleanest Way to Use React Query

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)
Enter fullscreen mode Exit fullscreen mode

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'],
  },
};
Enter fullscreen mode Exit fullscreen mode

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;
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

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,
      });
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

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;
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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:

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)