A Tiny Change in TanStack Query v5 That Made Us Rethink Our Codebase


Do you remember the first time you tried to fetch() something in React? That time you said to yourself, “This component needs some server data” so you spun up this very specific useEffect:

function UserCard({ userId }: UserCardProps) {
    const [user, setUser] = useState<User | undefined>(undefined);

    useEffect(() => {
        async function fetchUser() {
            const res = await fetch(`/api/users/${userId}`);
            const user = await res.json();
            setUser(user);
        }

        fetchUser();
    }, [userId]);

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

But even if you try to wrap it into a custom hook (useFetch feels familiar?) you immediately notice that this problem is not as trivial as you initially thought:

  1. How do I manage a loading state?
  2. How do I manage and display errors?
  3. What if the userId prop changes while the request is not done yet?
  4. What if another component needs the exact same data?
  5. How can I abort the request when the component unmounts?

And this list of ‘missing capabilities’ just keeps growing.

A meme to show writing your own fetch solution is a terrible headache

There’s a gut feeling that there must be a better way.

And that’s when you discover TanStack Query (or equivalent libraries).

TanStack Query is really one of those libraries that once you understand how powerful it is, you simply can’t work without it. It abstracts the problem of handling “async state” so well it doesn’t feel like a problem anymore.

You just:

function UserCard({ userId }: UserCardProps) {
    const query = useQuery({
        queryKey: ['user', userId],
        queryFn: () => fetchUser(userId),
    });

    if (query.isPending) return <div>loading...</div>;
    if (query.isError) return <div>error: {query.error}</div>;

    return <div>This is {query.data.name}</div>;
}

You breathe a sigh of relief… but it’s still not a perfect API. What if another component needs that same query?

The Story of TanStack Query’s API

There are multiple problems we immediately see:

  • The query key is “loosely” defined, so when I want to invalidate queries I’ll have to redeclare that ['user', userId], which leaves room for error and is not very DevEx-friendly.
  • The underlying fetchUser function is being used multiple times across the project.
  • We have fetch configuration inside React components which hurts separation of concerns.
  • And more…

To handle these concerns, TanStack Query recommended that we wrap all of our “query consumers” with custom hooks, and for the keys to have a “key factory” so we won’t have to construct keys manually.

// path/to/api.ts

// key factory pattern:
const userKeys = {
    root: ['users'] as const,
    all: () => [...userKeys.root, 'all'] as const,
    user: (userId: string) => [...userKeys.root, 'user', userId] as const,
    userFriends: (userId: string) =>
        [...userKeys.user(userId), 'friends'] as const,
};

// the underlying fetch function is NOT exported!
function fetchUser(id: string) {
    return fetch(`/user?id=${id}`).then((response) => response.json());
}

// to get the data you HAVE to consume it
export function useUser(userId: string) {
    return useQuery({
        queryKey: userKeys.user(userId),
        queryFn: () => fetchUser(userId),
    });
}

And if you need to invalidate data after successful mutations:

queryClient.invalidateQueries({ queryKey: userKeys.root });

There we have it. A single source of truth for our API layer.

But as time progresses, problems with this approach arise.

What if I want to get the current query data imperatively, for example in a non-React flow? We can’t use the underlying fetch function because developers might end up using it without going through the query cache layer.

const data = await queryClient.ensureQueryData({
    queryKey: userKeys.user(id),
    queryFn: ... // <- the fetch function is not exported!
})

Or even worse - What happens if I use the same key for two different fetch functions?

const query = useQuery({
    queryKey: ['users'],
    queryFn: () => fetchUsers(),
});
const query = useQuery({
    queryKey: ['users'],
    queryFn: () => fetchArchivedUsers(),
});

This is what Dominik had to say about it:

He says: undefined behaviour, not recommended. My guess is the 'later' one, but please don't do this. One query key needs to be paired with exactly one queryFn.

A red flag: if something is technically possible, eventually developers will do it. The fact that this is even possible signals there’s a deeper issue in how we’re approaching this. Why are we separating the keys from the fetch functions?

And that’s how in v5, queryOptions was born. More important than the function itself is the new premise: The key is tied to the fetch function.

// path/to/api.ts

export const userOptions = (id: number) =>
    queryOptions({
        queryKey: ['users', 'user', id],
        queryFn: () => fetchUser(id),
        staleTime: 5 * 1000,
    });

This is the only entity exported from the API file. A “query options” definition. This is our API, and it works for whatever we need.

useQuery(userOptions(1));
useQueries({
    queries: [userOptions(1), userOptions(2)],
});

queryClient.prefetchQuery(userOptions(23));
queryClient.invalidateQueries({ queryKey: userOptions(42).queryKey });
queryClient.setQueryData(userOptions(42).queryKey, newUser);

I will quote from Dominik’s article about this new function:

Separating QueryKey from QueryFunction was a mistake.

But apart from it being the new official recommendation, this new approach is an invitation for us to rethink how we use our API layer. This query options object has meaning without useQuery wrapping it.

What does it say about useQuery? It turns out that it’s just one way to communicate with our query cache. useQuery is just a “consumer” of the larger query cache. It subscribes to the cache and observes changes. In other words, the Query Core manages the state and useQuery simply manages the subscription to that state This is why multiple components can mount with the same useQuery call, but only a single fetch function will be fired.

A demo showing how a single useQuery call is just an observer to the actual core query
A visual representation of the query client cache. From my original talk at React IL (April 2025)

That single understanding is crucial if your app has non-obvious patterns.


Using a Global State Manager

You don’t always need global state management libraries, but when you do, they’re very helpful. They give us a handy way to manage state between components, but also options to calculate derived state efficiently.

For convenience, let’s take MobX for example. Here’s an example MobX class store - using the old decorator pattern for readability, just for demonstration.

class UsersStore {
    @observable users: User[] = [];
    @observable selectedId: string | null = null;

    @action addUser(user: User) {
        this.users.push(user);
    }

    @action setSelectedId(id: string | null) {
        this.selectedId = id;
    }

    @computed get selectedUser() {
        return this.users.find((u) => u.id === this.selectedId);
    }
}

A derived state like selectedUser is a cool mix-and-match between server state (users) and client state (selectedId). If multiple components use selectedUser, that find call is only performed once. This is a tiny example, but as your app grows, this could be a huge performance gain.

But if there’s one thing TanStack Query doesn’t want you to do, it’s copy your server state into local state. If we copy our users array from the query cache into UsersStore.users - we sever its connection to the query client. users no longer observes changes and we are doomed with data that’s no longer in sync and there’s nothing I hate more than multiple sources of truth.

But remember our key realization about useQuery? If it’s just a React adapter to the query cache, just like we also have adapters for Angular, Vue, Solid and Svelte -

TanStack Query’s recommendation to define queries in a React-agnostic way suggests that… we might be able to have an adapter for MobX?

A list of existing framework adapter and another row for MobX
Now we understand why they rebranded React Query to TanStack Query. From my original talk at React IL (April 2025)

Using TanStack Query Core tools, this becomes pretty straightforward. Here’s a very simplified implementation. It’s 80% of the actual working implementation, but for you to see how easy it is:

import { fromResource } from 'mobx-utils';
import {
    QueryObserver,
    type QueryObserverResult,
    type QueryObserverOptions,
} from '@tanstack/query-core';

class MobxQuery<Data> {
    @observable.ref private readonly observer?: QueryObserver<Data>;
    @observable private readonly resource: IResource<QueryObserverResult<Data>>;

    constructor(queryOptions: QueryObserverOptions) {
        this.observer = new QueryObserver(_queryClient_, queryOptions);
        this.resource = createQueryResource(this.observer);
    }

    @computed get query() {
        return this.resource.current();
    }
}

function createQueryResource<Data>(observable: QueryObserver<Data>) {
    let unsubscribe: () => void;
    return fromResource(
        (sink) => {
            unsubscribe = observable.subscribe(sink);
        },
        () => unsubscribe(),
        observable.getCurrentResult(),
    );
}

Leveraging the fact that both MobX and Query APIs work with a subscribe-unsubscribe-getSnapshot pattern, they integrate with each other perfectly.

Our MobxQuery.query will only recalculate and trigger re-renders if the query data cache changes. And now we can go back to our UsersStore and use it:

class UsersStore {
    // Instead of:
    // @observable users: User[] = [];
    usersQuery = new MobxQuery(usersOptions());

    @computed get users() {
        return this.usersQuery.query.data ?? [];
    }
}

Safely typed. Safely subscribed to the query cache. A single source of truth. And this is not only cool - this adapter is crucial in codebases where global state management libraries play a large role.


It Actually Works

Here at Connecteam, we heavily rely on MobX classes for complex user experiences where our state is less trivial and we need to spin up something more “state machine-y” while keeping it maintainable. For years we couldn’t really feel confident about our API, when TanStack Query recommended patterns that are bound to React. Syncing state from components into MobX stores with useEffects felt very wrong, and we had compromises we didn’t appreciate.

At first, using such an adapter might not feel right, as it mixes server state and client state together. But when you search for similar solutions online, it is clear that the community sees that gap. There are open-source adapters for Jotai, a first-party solution for Valtio, Redux is deeply integrated with RTK, and even for MobX there are multiple attempts on Github.

Try googling it!

Seeing developers online truly seek these advanced patterns for non-trivial use cases, we made our MobxQuery, MobxMutation and MobxQueries adapters. We were able to get rid of weird syncing patterns, which eliminated many weird behaviors and of course improved our memory footprint, network usage and most importantly - a true single source of truth.


Last Words

We mixed data and state without compromising on either of them. We have a pure logic class to manage complex states in, and the same data freshness TanStack Query gives us out of the box.

This is the most beautiful part. It’s a bit deep, to be honest. It all started with v5, where they introduced a much better, thought-out pattern to define APIs in. It provided the spark to think of queries in a framework-agnostic way, truly separating the network concern into its own layer.

Think about it - libraries don’t really want to bump major versions and introduce breaking changes. But this major version, regardless of the breaking changes it introduced, brought us a new, fresh and modern take on queries. They invited us to rethink our view of the world with them. The new recommendation encapsulates an entire evolution the library went through - and our project must keep pace to benefit from such findings.