Modern React Query Mistakes To Avoid

Tiger AbrodiTiger Abrodi
4 min read

1. Duplicating Server State with useState

The Problem: You're storing server data in local state and trying to update it manually for optimistic updates.

// ❌ Don't do this
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  const { data } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
    onSuccess: (data) => setUser(data), // Creating duplicate state
  });

  const handleUpdate = (newName) => {
    setUser((prev) => ({ ...prev, name: newName })); // Manual optimistic update
    updateUser.mutate({ userId, name: newName });
  };

  return <div>{user?.name}</div>;
}
// ✅ Use React Query's cache as single source of truth
function UserProfile({ userId }) {
  const queryClient = useQueryClient();

  const { data: user } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });

  const updateUser = useMutation({
    mutationFn: updateUserApi,
    onMutate: async (newData) => {
      await queryClient.cancelQueries(["user", userId]);

      const snapshot = queryClient.getQueryData(["user", userId]);

      queryClient.setQueryData(["user", userId], (old) => ({
        ...old,
        ...newData,
      }));

      return () => queryClient.setQueryData(["user", userId], snapshot);
    },
    onError: (error, variables, rollback) => rollback?.(),
    onSettled: () => queryClient.invalidateQueries(["user", userId]),
  });

  return <div>{user?.name}</div>;
}

Why this matters: React Query's cache is designed to be your single source of truth. When you duplicate it in useState, you're fighting against the library and losing all the benefits of automatic background updates, stale-while-revalidate, and proper cache invalidation.

2. Skipping Query Key Factories and Query Options

The Problem: You're writing query keys manually everywhere and losing type safety.

// ❌ Scattered keys with no consistency
const { data } = useQuery({
  queryKey: ['user', userId, 'posts'],
  queryFn: fetchUserPosts
})

// Later, manual typing required
queryClient.getQueryData<PostType[]>(['user', userId, 'posts'])
// ✅ Query factory + query options pattern
const userKeys = {
  all: ['users'] as const,
  detail: (id: string) => [...userKeys.all, id] as const,
  posts: (id: string) => [...userKeys.detail(id), 'posts'] as const,
}

const userQueries = {
  posts: (userId: string) => queryOptions({
    queryKey: userKeys.posts(userId),
    queryFn: () => fetchUserPosts(userId)
  })
}

// Usage -> automatic typing
const { data } = useQuery(userQueries.posts(userId))
queryClient.getQueryData(userQueries.posts(userId).queryKey) // No manual typing needed

Why this matters: Query key factories prevent typos and ensure consistency. Query options give you automatic type inference and make your queries reusable across components.

3. Wrapping Mutations in Try/Catch

The Problem: You're handling mutation success and error states outside of React Query's lifecycle.

// ❌ Manual error handling
const handleSubmit = async () => {
  try {
    await createPost.mutateAsync(formData);
    toast.success("Post created!");
    navigate("/posts");
  } catch (error) {
    toast.error("Failed to create post");
  }
};
// ✅ Use mutation callbacks
const createPost = useMutation({
  mutationFn: createPostApi,
  onSuccess: () => {
    toast.success("Post created!");
    navigate("/posts");
  },
  onError: () => {
    toast.error("Failed to create post");
  },
});

const handleSubmit = () => {
  createPost.mutate(formData);
};

Why this matters: React Query's mutation callbacks give you proper timing and error boundaries. They also work correctly with React's concurrent features and don't break when components unmount.

4. Invalidating Queries at the Wrong Time

The Problem: You're invalidating queries after mutateAsync instead of inside onSuccess.

// ❌ Wrong timing for invalidation
const handleCreate = async () => {
  await createPost.mutateAsync(data);
  queryClient.invalidateQueries(["posts"]); // Timing issues!
};
// ✅ Invalidate in onSuccess
const createPost = useMutation({
  mutationFn: createPostApi,
  onSuccess: () => {
    queryClient.invalidateQueries(["posts"]);
  },
});

const handleCreate = () => {
  createPost.mutate(data);
};

Why this matters: onSuccess guarantees the mutation succeeded before invalidating. It also handles edge cases like component unmounting or network errors that could leave your cache in an inconsistent state.

5. Using refetch() for State Changes

The Problem: You're manually refetching when dependent values change instead of including them in your query key.

// ❌ Manual refetching
const [filter, setFilter] = useState("all");

const { data, refetch } = useQuery({
  queryKey: ["todos"],
  queryFn: () => fetchTodos(filter),
});

useEffect(() => {
  refetch(); // Manual work
}, [filter]);
// ✅ Include dependencies in query key
const [filter, setFilter] = useState("all");

const { data } = useQuery({
  queryKey: ["todos", filter],
  queryFn: () => fetchTodos(filter),
});

Why this matters: Query keys should include all dependencies that affect the query result. When you include filter in the key, React Query automatically refetches when it changes. No manual work required.

6. Multiple useQuery Hooks for Related Data

The Problem: You're creating dependent queries with enabled conditions instead of fetching related data together.

// ❌ Dependent queries
const { data: user } = useQuery({
  queryKey: ["user", userId],
  queryFn: () => fetchUser(userId),
});

const { data: posts } = useQuery({
  queryKey: ["posts", userId],
  queryFn: () => fetchUserPosts(userId),
  enabled: !!user, // Creates dependency chain
});
// ✅ Fetch related data together
const { data } = useQuery({
  queryKey: ["user-with-posts", userId],
  queryFn: async () => {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchUserPosts(userId),
    ]);
    return { user, posts };
  },
});

Why this matters: Dependent queries create waterfall loading and make cache invalidation complex. Your queryFn can return any data structure you need -> use that flexibility to fetch related data in one go.

1
Subscribe to my newsletter

Read articles from Tiger Abrodi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tiger Abrodi
Tiger Abrodi

Just a guy who loves to write code and watch anime.