Modern React Query Mistakes To Avoid

Just a guy who loves to write code and watch anime.
Search for a command to run...

Just a guy who loves to write code and watch anime.
No comments yet. Be the first to comment.
Qualities that make outstanding builders.

Bipedal vs quadrupedal: how many legs This is about the number of legs the creature walks on. Bipedal. Two legs. Humans, ostriches, T-rex, kangaroos, most fantasy humanoids. Quadrupedal. Four legs. Do

Intro You hear "lerp" and "smoothstep" everywhere in game dev. They sound like math jargon. They're not. Both are small tools that do the same job: smoothly move from one value to another. The problem

What is Kinematics Kinematics is the math field. It's the study of how things move without worrying about forces (which would be dynamics). FK and IK are the two branches: forward kinematics and inver

Intro Textures are usually the biggest cost in a 3D scene. Memory, bandwidth, and load time all get eaten by them. Resizing them is the obvious lever. There's more. This post is about the less obvious

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.
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.
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.
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.
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.
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.