I’ve been working with Tanstack Query for a while now and I’m a big, big, fan. I love how it works within my apps, nestled amongst my standard React code without needing a tonne of boilerplate, and how it takes care of the hard bits of caching for me.

Nobody wants to roll their own solutions to these hard problems1, and Tanstack Query “just works” for the projects I’ve used it on.

Big props to TkDodo for his continued support!

Extracting data with custom hooks

Lately, I’ve been looking at slicing data with the select feature. TkDodo explains this on his blog: React Query Data Transformations but I thought I’d write up my learnings and talk about how to use this with TypeScript.

The select feature behaves like a redux selector. That is, it lets you take a larger piece of state, and slice it up to extract just the parts you’re interested in.

Quick example

Here we have an example custom hook which uses Tanstack Query’s useQuery API to request our Alien data.

useGetAlien.ts
import { useQuery } from '@tanstack/react-query';
export type Alien = {
id: number;
name: string;
colour: string;
eyes: number;
arms: number;
legs: number;
};
export function useGetAlien({ id }: { id: number }) {
return useQuery({
queryKey: ['alien', { id }],
queryFn: async () => {
// This is where you would fetch the user data from an API (e.g. axios.get<Alien>(`/api/alien/${id}`))
return {
id: 123,
name: 'Baglorag Snargleblurf',
colour: 'green',
eyes: 3,
arms: 8,
legs: 3,
} as Alien;
},
});
}

This hook is being used on this page!

You can see the result in the developer tools console or use React Query DevTools in the lower right corner to see the query and observers.

You may have been expecting some Generic Gymnastics in the code above, in order to type the return values and support all options with UseQueryOptions<...>.

For the most part, the recommendation is to let TypeScript do it’s thing and infer the values for you, but we do need to give it a helping hand when dealing with remote data (e.g. from an API call).

There is an element of lying to TS here to tell it we’re returning an Alien from our queryFn, but it’s the best we can do unless we plan on adding something like Zod to parse the returned data. However, we now have correctly typed data in our useGetAlien() hook.

This makes sense, but what about the type of data returned by select? That doesn’t return the same data, and we could have multiple select’s returning different data couldn’t we?

Slicing data with select

So, let’s create a custom useGetAlienEyeCount() hook which uses select to slice off just the eyes count from the Alien data.

useGetAlienEyeCount.ts
import { useGetAlien, type Alien } from './useGetAlienWithStringSelect';
const selectEyeCount = (alien: Alien) => alien?.eyes ?? 0;
export function useGetAlienEyeCount() {
return useGetAlien({
id: 123,
options: {
select: selectEyeCount,
},
});
}

This is really neat!

We can re-use the same state and slice off the bits we want without needing to make extra requests to other endpoints for the same data, or repeat the boilerplate of the original query just to return different data. This works especially well if we have data keyed off a single object (like Site or User with many properties).

And so, we can update our custom hook accordingly.

useGetAlien.ts
import { useQuery } from '@tanstack/react-query';
export type Alien = {
id: number;
name: string;
colour: string;
eyes: number;
arms: number;
legs: number;
};
export function useGetAlien({
id,
options,
}: {
id: number;
options?: {
// eslint-disable-next-line no-unused-vars
select?: (alien: Alien) => number;
};
}) {
return useQuery({
queryKey: ['alien', { id }],
queryFn: async () => {
// This is where you would fetch the user data from an API (e.g. axios.get<Alien>(`/api/alien/${id}`))
return {
id: 123,
name: 'Baglorag Snargleblurf',
colour: 'green',
eyes: 3,
arms: 8,
legs: 3,
} as Alien;
},
...options,
});
}

But there’s a problem. We’ve now told TS that our select function returns a number, but what if we later add another custom hook with a select function that returns the name of the Alien? That’s a string return instead.

useGetAlienName.ts
import { useGetAlien, type Alien } from './useGetAlienWithStringSelect';
const selectName = (alien: Alien) => alien?.name;
export function useGetAlienName({ id }: { id: number }) {
return useGetAlien({
id,
options: {
//@ts-expect-error: selectName returns a string, but it's expecting a number
// Type '(alien: Alien) => string' is not assignable to type '(alien: Alien) => number'.
// Type 'string' is not assignable to type 'number'.ts(2322)
select: selectName,
},
});
}

We could then start using union types number | string but that’s going to become messy and less and less accurate, until eventually we might start reaching for the dreaded any

TypeScript generics to the rescue

The trick is to allow a Generic to be passed into the hook, while defaulting to the full data type returned when a select is not in use.

useGetAlien.ts
import { useQuery } from '@tanstack/react-query';
export type Alien = {
id: number;
name: string;
colour: string;
eyes: number;
arms: number;
legs: number;
};
export function useGetAlien<T = Alien>({
id,
options,
}: {
id: number;
options?: {
// eslint-disable-next-line no-unused-vars
select?: (alien: Alien) => T;
};
}) {
return useQuery({
queryKey: ['alien', { id }],
queryFn: async () => {
// This is where you would fetch the user data from an API (e.g. axios.get<Alien>(`/api/alien/${id}`))
return {
id: 123,
name: 'Baglorag Snargleblurf',
colour: 'green',
eyes: 3,
arms: 8,
legs: 3,
} as Alien;
},
...options,
});
}

This way if we use a select function in another custom hook, the return type of the select is passed through.

Otherwise, if no select is used, it defaults to the full data we expect from the hook (the Alien). Both of our custom hooks now work as expected, and have the correct type inference based on the data they return.

Considerations

This isn’t the only way to set these up though. We could go full hog and provide all the Generics, all the time, and type all the options (with UseQueryOptions<...>) but it’s not recommended.

TkDodo has said that new Generics may be introduced, and if we’re currently only providing the existing ones from today, this may break on future upgrades.

Or, alternatively, we could abstract the queryFn into its own importable function and then use that in two separate hooks with mostly duplicate useQuery code (though return data type would be different).

So for now, using select with a little sprinkle of a Generic has worked great for me.

Thanks for reading! 💜

Fancy another?


Footnotes

  1. There are only two hard things in Computer Science: cache invalidation and naming things.” - Phil Karlton