Rodrigo's Website

Common misunderstandings with TanStack Query

Introduction

I have seen the TanStack Query library used improperly, leading to subtle intermittent issues. This happens because TanStack assumes best practices that are sometimes not understood. In this article, I hope to explain some React concepts that are commonly misunderstood to clarify the correct usage of the library.

React concepts

Let's start with a simple React component.

export function Greet(props: { name: string }) {
  return <div>Hello {props.name}!</div>;
}

This component, like all React components, has one job: given some props and state, what elements should be rendered in the browser? React will call this function every time it suspects that it might return a different value, for example when state or props change. In other words, React will call this function to check whether the HTML on the page needs to change, and this function should only use the props and state to do so. Look at the following example:

export function Greet(props: { name: string }) {
  fetch("https://example.com/greet");

  return <div>Hello {props.name}!</div>;
}

Why is this very wrong? It is because every time React is checking whether the DOM needs to be updated, an API call is made as a side effect. Remember, we are only interested in the HTML given some props and state; this request has nothing to do with it. It is true that we could be interested in the response to generate different HTML, but this needs to be done in a different place. Regardless of where the API call is made, it needs to be converted to state or prop changes in order to be rendered.

In a component, we really only want to calculate the HTML based on props and state, but then, how are state and props updated? Besides the component function, there are other places where we can pass functions to React to call, for example, we can pass an event handler.

export function Message() {
  const [response, setResponse] = useState("");

  const clickHandler = () => {
    fetch("https://example.com/messages", { method: 'POST' })
      .then((r) => r.text())
      .then((message) => setResponse(message));
  };

  return (
    <>
      <button onClick={clickHandler}>Send message</button>
      <p>{response}</p>
    </>
  );
}

Unlike the code inside the component, in clickHandler we can have imperative code in the sense of "if this, then do that" code that doesn't belong to the component body.

const [message, setMessage] = useState("");

The value message should be used inside the component. The function setMessage should be used anywhere except in the component, for example, in the event handler. Note the contrast between imperative and reactive code in React: they serve different purposes, are in different places, and should not be mixed.

Knowing that not everything will fit this model, React added an escape hatch for the rare exceptions where this does not apply.

import { useRef, useEffect } from 'react';
import { MapWidget } from './map-widget.js';

export default function Map({ zoomLevel }) {
  const containerRef = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    if (mapRef.current === null) {
      mapRef.current = new MapWidget(containerRef.current);
    }

    const map = mapRef.current;
    map.setZoom(zoomLevel);
  }, [zoomLevel]);

  return (
    <div
      style={{ width: 200, height: 200 }}
      ref={containerRef}
    />
  );
}

In this example from the React documentation we have one of the rare use cases for useEffect. This map widget library is not a React library so it only has imperative bindings to it, meaning you can't pass props to change its behavior, you can only call functions. In this case, you have to use useEffect to convert an imperative-based API to a reactive API to use in React. Also note that we have to mutate the variable mapRef, another thing that we usually don't do in React, and this is only required because we are using a library that was not made for React. If you find yourself using refs and useEffect with libraries made for React like TanStack Query or with normal React components, then this is a strong sign that you are doing something wrong.

useEffect should be used carefully. For example, on September 12, 2025, Cloudflare reported that a buggy useEffect dependency setup in its dashboard was one of the immediate triggers of a major API and Dashboard outage.

TanStack Query

TanStack Query helps transform the imperative fetch API into an API that can be used in React. It also solves many common problems that dashboards in React have. The library has two major primitives, the mutation useMutation and the query useQuery. Looking at the useState example from before that had two values, one reactive and another imperative, the query in TanStack is analogous to the reactive value and the mutation is analogous to the set state function. They also need to be used in the same places, so the query must go in the component code with all the hook calls and the mutate function in event handlers.

import { useQueryClient, useMutation, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import * as v from "valibot";

export function Todos() {
  const queryClient = useQueryClient();
  const [status, setStatus] = useState("");

  const query = useQuery({
    queryKey: ["todos"],
    queryFn: () =>
      fetch("https://www.example.com/todos")
        .then((response) => response.json())
        .then((response) => v.parse(v.array(v.object({ id: v.string(), title: v.string() })), response)),
  });

  const mutation = useMutation({
    mutationFn: ({ title }: { title: string }) =>
      fetch("https://www.example.com/todos", {
        method: "POST",
        body: JSON.stringify({ title }),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  const clickHandler = () => {
    const onSuccess = () => {
      setStatus("I have things to do");
    };

    mutation.mutate({ title: "Do Laundry" }, { onSuccess });
  };

  return (
    <div>
      <ul>
        {query.data?.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>

      <button onClick={clickHandler}>Add Todo</button>
      <p>{status}</p>
    </div>
  );
}

This is a small example adapted from the official documentation. Here I want to point out several important points on how this library should be used.

export function Todos() {
  // ...

  const query = useQuery({
    /*... */
  });

  // ...

  return ...;
}

Note how the query is inside the component. It is not in a useEffect or an event handler, its value is used directly in the component. TanStack here transformed our imperative fetch into state for us to use in the component.

queryFn: () =>
  fetch("https://www.example.com/todos")
    .then((response) => response.json())
    .then((response) => v.parse(v.array(v.object({ id: v.string(), title: v.string() })), response)),

Note that the queryFn is imperative, and that is where you should place the imperative code. For example, imagine that for some reason you have to call two APIs in sequence. Please, do not put the data from the query in a useEffect or force the fetch to happen with refetch. Instead, simply create an async function in the queryFn, where you are free to use any imperative code, like waiting for the first response before calling the second API.

queryFn: async () => {
  const keyResponse = await fetch("https://www.example.com/key").then((r) => r.json());
  const { key } = v.parse(v.object({ key: v.string() }), keyResponse);
  const todosResponse = await fetch(`https://www.example.com/${key}`).then((r) => r.json());
  return v.parse(v.array(v.object({ id: v.string(), title: v.string() })), todosResponse);
}
        

Also note that you can put anything you want in the queryFn.

If you have some browser API that is imperative, like requesting clipboard permission, you can also put that code in the queryFn to avoid the useEffect. In a way, the query allows you to transform any imperative async code that returns data into the React world.

function useClipboardWritePermission() {
  return useQuery({
    queryKey: ["permission", "clipboard-write"],
    queryFn: () => navigator.permissions.query({ name: "clipboard-write" as PermissionName }),
    staleTime: 60_000,
    gcTime: 10 * 60_000,
    retry: false,
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
  });
}
        
fetch("https://www.example.com/todos")

Also note that, in general, GET requests are queries and other methods like POST are mutations. This happens because GET requests in general return data to be rendered, and POST requests, in general, change the state of the application on the server.

v.parse(v.array(v.object({ id: v.string(), title: v.string() })), response)

Still in the query, note how I don't simply cast the type of the response coming from the server. Instead of blindly trusting that the response of the server has a specific shape using something like as Todos or God forbid as any, I parse the value using a library to make sure I have the correct type. In general, if you don't know the type of a value, always assume it is unknown, not any, and verify if the value actually belongs to a type. Don't worry about extra fields, like in TypeScript that features struct typing, extra fields are allowed, we are only checking for the fields we actually use to avoid errors in the middle of the application.

queryKey: ["todos"]

Another thing to note: imagine that we had two components displaying the same data. Do we want to do two fetches requesting the exact same data? We don't. That is why the fetch doesn't only happen on mount like it would if we were using useEffect. It happens when the data is missing or the cache is stale. That is why the key is necessary, because it allows TanStack Query to cache this data and only request it when necessary.

If you inspect the requests being made, you are going to see that there are more requests than usual. This happens because TanStack Query by default tries to keep the content as fresh and updated as possible. It shows cached data and refetches stale queries in the background when the window refocuses or new instances mount. This attempt to keep data fresh is often overlooked, and it is common to find the default behavior disabled.

const mutation = useMutation({
  mutationFn: ({ title }: { title: string }) =>
    fetch("https://www.example.com/todos", {
      method: "POST",
      body: JSON.stringify({ title }),
    }),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});
mutationFn: ({ title }: { title: string })

Now, let's look at the mutation. First thing to notice is that the argument, title, must be the argument of the mutation function that is passed when we are actually mutating the value, not before. Look at this wrong example.

function useSetTodo(title: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: () =>
      fetch("https://www.example.com/todos", {
        method: "POST",
        body: JSON.stringify({ title }),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}
export function Todos() {
  const { mutate: setTodo } = useSetTodo(???);

  const clickHandler = (todo) => {
    setTodo({ title: todo.title });
  };

  return (...);
}

Why this is wrong? Because we typically only know the title in the imperative code, in the event handler, in a callback, not when we are rendering the component and calling the hooks.

onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ["todos"] });
},

After a mutation, the state on the server will change and the application needs to update its state to match the one on the server. One way of doing this is by indicating that the cache is no longer valid and the request to fetch the data needs to be done again. Optimistic updates are another way to sync the application state with the server state.

const clickHandler = () => {
  const onSuccess = () => {
    setStatus("I have things to do");
  };

  mutation.mutate({ title: "Do Laundry" }, { onSuccess });
};

And this is where you should be calling the mutate function, in event handlers or other places where imperative code is expected. Note that there are two onSuccess handlers, one in the hook where the mutation is defined and another in the mutate function. They are different, the one here, in the mutate function, is not called after the component is unmounted, so this is the right place to update the component state, since we don't want to try to update component state after unmount. The other one, in the hook definition, is tied to the mutation itself and can still run independently of the component lifecycle. That is the right place to invalidate the cache.

return (
  <div>
    <ul>
      {query.data?.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>

    <button onClick={clickHandler}>Add Todo</button>
    <p>{status}</p>
  </div>
);

And here we display the results. Data can be undefined for a reason, before the API call is done, we won't have a value for it. If you want, you can use Suspense instead and have the loading states being handled separately. In this case, data will always be defined. Or you can check if data is pending and for error, the type was carefully constructed in a way that after you check the status of the query the type of data will be resolved, so the following code has no TypeScript errors. No need for ? in data.map.

export function Todos() {
  const { data, isPending, isError } = useQuery({ ... });

  if (isPending) {
    return <p>Loading.</p>;
  }

  if (isError) {
    return <p>Error.</p>;
  }

  return (
    <div>
      <ul>
        {data.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}
        

Conclusion

Here we reviewed some concepts in React with direct analogs in TanStack Query: what they mean, how they relate, and how they should be used. In particular, how different imperative code is from reactive code and how they should not be confused with each other. Additionally, we covered some anti-patterns that should be avoided to prevent issues.