A background image for the simonporter blog

Slicing Data with Tanstack Query

Slicing data with Tanstack Query and TypeScript. TypeScript gotchas and other considerations when using the `select` feature.

4 min read

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!

Slicing Data

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 learnings2 and talk about how to use them with TypeScript.

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

Quick Example

import { useGetUser } from "./app/hooks/useGetUser";
import { useGetGender } from "./app/hooks/useGetGender";

export default function App() {
  const userId = 1;
  const useGetUserQuery = useGetUser({ userId });
  const useGetGenderQuery = useGetGender({ userId });

  if (useGetUserQuery?.isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div className="h-full w-full">
      <div className="max-w-md min-w-min m-auto grid gap-1 border-2 border-green-500 p-4 grid-cols-[100px_1fr] [grid-template-areas:'avatar_title''avatar_name''avatar_details''description_description']">
        <p className="[grid-area:title]">{`My Awesome App`}</p>
        <div className=" h-16 w-16 rounded-full [grid-area:avatar] bg-slate-400">
          <img src={useGetUserQuery?.data?.image} alt="aw3som3 avatar foto" />
        </div>
        <div className="[grid-area:name]">
          <h1 className="text-xl font-bold">{`Hello ${useGetUserQuery?.data?.username}!`}</h1>
        </div>
        <div className="[grid-area:details]">
          <p className="text-s text-slate-500">{`Gender: ${useGetGenderQuery?.data}`}</p>
        </div>
        <div className="[grid-area:description] pt-4">{`I work for ${useGetUserQuery?.data?.company?.name} as a ${useGetUserQuery?.data?.company?.title} in the ${useGetUserQuery?.data?.company?.department} department!`}</div>
      </div>
    </div>
  );
}

The above example uses the useGetUser() custom hook which calls useQuery to request User data from our backend.

TSX
export function useGetUser<T = User>({
    userId,
    options
}: {
    userId: number;
    options?: {
        select?: (user: User) => T;
    };
}) {
return useQuery({
    queryKey: ["user", { userId }],
    queryFn: async () => {
        return dataOne as User;
    },
    ...options
});
}

We've then created a custom useGetGender() hook which uses select to slice off just the gender value from this data.

TSX
const selectGender = (user: User) => user?.gender;

export function useGetGender({ userId }: { userId: number }) {
    return useGetUser({
        userId,
        options: {
            select: selectGender,
        },
    })
}

This is really powerful!

You can re-use the same state and slice off the bits you want without needing to make extra requests to other endpoints for the exact same data. Preventing more request waterfalls3 and potentially reducing rerenders4.

TypeScript Settings

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. I know, cheating right?

TSX
return useQuery({
    queryKey: ["user", { userId }],
    queryFn: async () => {
        return dataOne as User;
    },
    ...options
});

This is lying to TS to tell it we're returning a User, but unless you plan on adding Zod, this is about the best you can do. This then lets TS infer the return of our own custom hook, because useQuery infers what data it will return from the queryFn and so our custom hook infers the same return type.

code snippet showing the inferred return type after casting the returned data

This makes sense, but what about the select ReturnType? That doesn't return the same data, and we could have multiple select's returning different data couldn't we?

We could always return what we need for this particular select, but that won't work for others with other data types.

TSX
export function useGetUser({
    userId,
    options
}: {
    userId: number;
    options?: {
        select?: (user: User) => string // Help??;
    };
}) {

Rather than start making unions for each data type you want returned, the solution is to use a generic on the custom hook to adjust the Return Type with a default. 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 data we expect (the User).

code snippet showing the inferred return type of select passed as the generic of the custom useQuery hook

Considerations

This isn't the only way to set these up though. You 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 will be introduced in v5, and if you are currently only provide the existing ones, this may break on upgrade.

I'm happy with the trade offs from the above setup though, most inference with least duplication.

Footnotes

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

  2. In my initial blog post, I write about why I wanted to write about my learnings. To cement my knowledge mostly, and if it's beneficial to others, that's a plus too!

  3. Take a look at the GTMetrix blog for an explanation of request waterfalls.

  4. It's actually a little more involved than that, while it can help, you should be aware of render optimizations in React Query first.