Skip to content
TkDodo's blog

Creating Query Abstractions

Creating Query Abstractions
Photo by Earl Wilcox

Developers love creating abstractions. We see some code that we’ll need in a different place - abstraction. Need this 3-liner, but slightly different - abstraction (with a flag). Need something that every useQuery should do - Create an aBsTrAcTiOn!

There’s nothing wrong with abstractions per se, but they have tradeoffs, like everything else. Dan’s talk The wet codebase (opens in a new window) is among one of my favourite talks ever, and he explains this really well.

Custom Hooks

In React, creating abstractions very often correlates with custom hooks. They are great for sharing logic between multiple components, or even just hiding that gnarly useEffect behind a good name. For the longest time, creating your own abstraction over useQuery meant writing a custom hook:

useInvoice
function
function useInvoice(id: number): UseQueryResult<Invoice, Error>
useInvoice
(
id: number
id
: number) {
return
useQuery<Invoice, Error, Invoice, (string | number)[]>(options: UndefinedInitialDataOptions<Invoice, Error, Invoice, (string | number)[]>, queryClient?: QueryClient): UseQueryResult<Invoice, Error> (+2 overloads)
useQuery
({
queryKey: (string | number)[]
queryKey
: ['invoice',
id: number
id
],
queryFn?: unique symbol | QueryFunction<Invoice, (string | number)[], never>
queryFn
: () =>
function fetchInvoice(id: number): Promise<Invoice>
fetchInvoice
(
id: number
id
),
})
}
const { data } =
function useInvoice(id: number): UseQueryResult<Invoice, Error>
useInvoice
(1)
const data: Invoice | undefined

This is straightforward, and I can now call useInvoice() wherever I want instead of having to repeat the queryKey and queryFn all the time. It ensures consistency for the queryKey, which could otherwise lead to duplicate cache entries. And because it just returns what useQuery returns, we have an interface that is aligned with TanStack Query’s API surface, so there’s no surprise naming where this hook is used.

Types are also fully inferred, because we don’t annotate any generics manually anywhere, which is great. The more our TypeScript code looks like plain JavaScript, the better.

Query Options

But what about the input to this custom hook ? useQuery has 24 options, and we with the current abstraction, we can’t pass any of those in. What if we want to pass a different staleTime for one of our screens where getting background updates isn’t as important? Sure, I guess we’ll just accept that as another parameter:

staleTime
function
function useInvoice(id: number, staleTime: number): UseQueryResult<Invoice, Error>
useInvoice
(
id: number
id
: number,
staleTime: number
staleTime
: number) {
return
useQuery<Invoice, Error, Invoice, (string | number)[]>(options: UndefinedInitialDataOptions<Invoice, Error, Invoice, (string | number)[]>, queryClient?: QueryClient): UseQueryResult<Invoice, Error> (+2 overloads)
useQuery
({
queryKey: (string | number)[]
queryKey
: ['invoice',
id: number
id
],
queryFn?: unique symbol | QueryFunction<Invoice, (string | number)[], never>
queryFn
: () =>
function fetchInvoice(id: number): Promise<Invoice>
fetchInvoice
(
id: number
id
),
staleTime?: StaleTimeFunction<Invoice, Error, Invoice, (string | number)[]>
staleTime
,
})
}

This still looks okay I guess, but next thing you know, somebody wants to integrate the Query with Error Boundaries, so they want to pass throwOnError. Okay, but that many parameters isn’t a good interface, I guess we should’ve just made it an object in the first place:

Another Option
function
function useInvoice(id: number, options?: {
staleTime?: number;
throwOnError?: boolean;
}): UseQueryResult<Invoice, Error>
useInvoice
(
id: number
id
: number,
options: {
staleTime?: number;
throwOnError?: boolean;
} | undefined
options
?: {
staleTime?: number
staleTime
?: number;
throwOnError?: boolean
throwOnError
?: boolean },
) {
return
useQuery<Invoice, Error, Invoice, (string | number)[]>(options: UndefinedInitialDataOptions<Invoice, Error, Invoice, (string | number)[]>, queryClient?: QueryClient): UseQueryResult<Invoice, Error> (+2 overloads)
useQuery
({
queryKey: (string | number)[]
queryKey
: ['invoice',
id: number
id
],
queryFn?: unique symbol | QueryFunction<Invoice, (string | number)[], never>
queryFn
: () =>
function fetchInvoice(id: number): Promise<Invoice>
fetchInvoice
(
id: number
id
),
...
options: {
staleTime?: number;
throwOnError?: boolean;
} | undefined
options
,
})
}

At this point, you’re likely wondering if you’re still on the right track. Always having to touch our small abstraction whenever there’s a new use-case that React Query covers doesn’t seem ideal. For the return value, we’ve chosen to stick to what the library returns - so can’t we just do the same thing for the options we get passed in?

UseQueryOptions

We dig a bit deeper and find out that React Query exposes a type called UseQueryOptions - sounds like what we want:

UseQueryOptions
import type {
interface UseQueryOptions<TQueryFnData = unknown, TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = readonly unknown[]>
UseQueryOptions
} from '@tanstack/react-query'
function
function useInvoice(id: number, options?: Partial<UseQueryOptions>): UseQueryResult<unknown, Error>
useInvoice
(
id: number
id
: number,
options: Partial<UseQueryOptions<unknown, Error, unknown, readonly unknown[]>> | undefined
options
?:
type Partial<T> = { [P in keyof T]?: T[P]; }
Partial
<
interface UseQueryOptions<TQueryFnData = unknown, TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = readonly unknown[]>
UseQueryOptions
>) {
return
useQuery<unknown, Error, unknown, readonly unknown[]>(options: UndefinedInitialDataOptions<unknown, Error, unknown, readonly unknown[]>, queryClient?: QueryClient): UseQueryResult<unknown, Error> (+2 overloads)
useQuery
({
queryKey: readonly unknown[]
queryKey
: ['invoice',
id: number
id
],
queryFn?: unique symbol | QueryFunction<unknown, readonly unknown[], never>
queryFn
: () =>
function fetchInvoice(id: number): Promise<Invoice>
fetchInvoice
(
id: number
id
),
...
options: Partial<UseQueryOptions<unknown, Error, unknown, readonly unknown[]>> | undefined
options
,
})
}

There are no type errors, so this works, right? Well, let’s look at a usage again:

Unknown
const { data } =
function useInvoice(id: number, options?: Partial<UseQueryOptions>): UseQueryResult<unknown, Error>
useInvoice
(1, {
throwOnError?: ThrowOnError<unknown, Error, unknown, readonly unknown[]>
throwOnError
: true })
const data: unknown

Our data has become of type unknown. This might be unexpected, but it all goes back to how Query uses Generics for ideal type inference. I’ve written about this before in #6: React Query and TypeScript. The problem might become more obvious once we inspect what options actually infers to:

UseQueryOptions<unknown>
declare const options: UseQueryOptions
const options: UseQueryOptions<unknown, Error, unknown, readonly unknown[]>

UseQueryOptions has the same four generics, and if we omit them, its default values are taken instead. The default for data happens to be unknown, so when we spread those options onto our useQuery, the types are widened to unknown.

TypeScript Libraries

I’ve found this to be a common problem with libraries that try to give you a lot of type safety through type inference. They tend to work really, really well when used “directly”, but as soon as you try to create low-level, generic abstractions over them, it becomes difficult to get right.

TanStack Query only has four generics, so we might be able to just re-create them. TanStack Form has 23 type parameters on most of the types and TanStack Router - let’s better not talk about that. 😂

So clearly, this only works to some degree. I have a four-year-old tweet about how to get it going with TanStack Query, but honestly, it’s a mess:

Avatar for TkDodo
Dominik 🔮
@TkDodo

I have been asked a lot lately how to make your own low-level abstraction over useQuery and have it work in #TypeScript. My answer is usually: You don’t need it, as those abstractions are often too wide. But there are use-cases for it, so here is my take. Let’s break it down ⬇️

UseApi

- Feb 9, 2022

The Naive Solution

And because it’s so complicated, I’m seeing this done wrong all the time. The naive solution is to just declare the first type parameter on UseQueryOptions:

UseQueryOptions<Invoice>
function
function useInvoice(id: number, options?: Partial<UseQueryOptions<Invoice>>): UseQueryResult<Invoice, Error>
useInvoice
(
id: number
id
: number,
options: Partial<UseQueryOptions<Invoice, Error, Invoice, readonly unknown[]>> | undefined
options
?:
type Partial<T> = { [P in keyof T]?: T[P]; }
Partial
<
interface UseQueryOptions<TQueryFnData = unknown, TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = readonly unknown[]>
UseQueryOptions
<
type Invoice = {
id: number;
createdAt: string;
}
Invoice
>>,
) {
return
useQuery<Invoice, Error, Invoice, readonly unknown[]>(options: UndefinedInitialDataOptions<Invoice, Error, Invoice, readonly unknown[]>, queryClient?: QueryClient): UseQueryResult<Invoice, Error> (+2 overloads)
useQuery
({
queryKey: readonly unknown[]
queryKey
: ['invoice',
id: number
id
],
queryFn?: unique symbol | QueryFunction<Invoice, readonly unknown[], never>
queryFn
: () =>
function fetchInvoice(id: number): Promise<Invoice>
fetchInvoice
(
id: number
id
),
...
options: Partial<UseQueryOptions<Invoice, Error, Invoice, readonly unknown[]>> | undefined
options
,
})
}
const { data } =
function useInvoice(id: number, options?: Partial<UseQueryOptions<Invoice>>): UseQueryResult<Invoice, Error>
useInvoice
(1, {
throwOnError?: ThrowOnError<Invoice, Error, Invoice, readonly unknown[]>
throwOnError
: true })
const data: Invoice | undefined

This “works” to infer data again, but falls apart if we need options that rely on other type parameters, like select:

select
const {
const data: Invoice | undefined
data
} =
function useInvoice(id: number, options?: UseQueryOptions<Invoice>): UseQueryResult<Invoice, Error>
useInvoice
(1, {
select: (
invoice: Invoice
invoice
) =>
invoice: Invoice
invoice
.
createdAt: string
createdAt
,
Error ts(2322) ― Type '(invoice: Invoice) => string' is not assignable to type '(data: Invoice) => Invoice'. Type 'string' is not assignable to type 'Invoice'.
})

As the tweet shows, we can add more type parameters to our own abstractions, but this is takes us further away from code that looks like Just JavaScript™. The promise was that those libraries do the ugly TypeScript stuff for us so we don’t have to …

Finding Better Abstractions

I’ve come to the conclusion that custom hooks are just not the right abstraction here, and that has multiple reasons:

The Query Options API

Since v5, my preferred way to create Query abstractions is not with custom hooks anymore but with queryOptions.

That API solves all mentioned problems and more. We can use it between different hooks, and even share it with imperative functions. It’s just a regular function, so it works anywhere. At runtime, it doesn’t do anything. Here’s the transpiled output:

queryOptions.js
function queryOptions(options) {
return options
}

But on type level, it becomes a real powerhouse, making it the best way to share query configurations:

invoiceOptions
import {
function queryOptions<TQueryFnData = unknown, TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = readonly unknown[]>(options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
} (+2 overloads)
queryOptions
} from '@tanstack/react-query'
function
function invoiceOptions(id: number): OmitKeyof<UseQueryOptions<Invoice, Error, Invoice, (string | number)[]>, "queryFn"> & {
queryFn?: QueryFunction<Invoice, (string | number)[], never>;
} & {
queryKey: (string | number)[] & {
[dataTagSymbol]: Invoice;
[dataTagErrorSymbol]: Error;
};
}
invoiceOptions
(
id: number
id
: number) {
return
queryOptions<Invoice, Error, Invoice, (string | number)[]>(options: UnusedSkipTokenOptions<Invoice, Error, Invoice, (string | number)[]>): OmitKeyof<UseQueryOptions<Invoice, Error, Invoice, (string | number)[]>, "queryFn"> & {
queryFn?: QueryFunction<Invoice, (string | number)[], never>;
} & {
queryKey: (string | number)[] & {
[dataTagSymbol]: Invoice;
[dataTagErrorSymbol]: Error;
};
} (+2 overloads)
queryOptions
({
queryKey: (string | number)[]
queryKey
: ['invoice',
id: number
id
],
queryFn?: QueryFunction<Invoice, (string | number)[], never>
queryFn
: () =>
function fetchInvoice(id: number): Promise<Invoice>
fetchInvoice
(
id: number
id
),
})
}
const { data:
const invoice1: Invoice | undefined
invoice1
} =
useQuery<Invoice, Error, Invoice, (string | number)[]>(options: UndefinedInitialDataOptions<Invoice, Error, Invoice, (string | number)[]>, queryClient?: QueryClient): UseQueryResult<Invoice, Error> (+2 overloads)
useQuery
(
function invoiceOptions(id: number): OmitKeyof<UseQueryOptions<Invoice, Error, Invoice, (string | number)[]>, "queryFn"> & {
queryFn?: QueryFunction<Invoice, (string | number)[], never>;
} & {
queryKey: (string | number)[] & {
[dataTagSymbol]: Invoice;
[dataTagErrorSymbol]: Error;
};
}
invoiceOptions
(1))
data: Invoice | undefined
const { data:
const invoice2: Invoice
invoice2
} =
useSuspenseQuery<Invoice, Error, Invoice, (string | number)[]>(options: UseSuspenseQueryOptions<Invoice, Error, Invoice, (string | number)[]>, queryClient?: QueryClient): UseSuspenseQueryResult<Invoice, Error>
useSuspenseQuery
(
function invoiceOptions(id: number): OmitKeyof<UseQueryOptions<Invoice, Error, Invoice, (string | number)[]>, "queryFn"> & {
queryFn?: QueryFunction<Invoice, (string | number)[], never>;
} & {
queryKey: (string | number)[] & {
[dataTagSymbol]: Invoice;
[dataTagErrorSymbol]: Error;
};
}
invoiceOptions
(2))
data: Invoice

Okay, this solves the interoperability problem, but how can I pass options now? If we’re adding options as a param to invoiceOptions, we’re back to square one.

Composing QueryOptions

Well, that’s the good news: We don’t have to do that. The idea is that invoiceOptions only contains the options we want to share between every usage. The best abstractions are not configurable, so we just keep it like that. If we want to set other options, we just pass them on top of invoiceOptions directly at the usage sites:

Composing QueryOptions
import {
function queryOptions<TQueryFnData = unknown, TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = readonly unknown[]>(options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
} (+2 overloads)
queryOptions
} from '@tanstack/react-query'
function
function invoiceOptions(id: number): OmitKeyof<UseQueryOptions<Invoice, Error, Invoice, (string | number)[]>, "queryFn"> & {
queryFn?: QueryFunction<Invoice, (string | number)[], never>;
} & {
queryKey: (string | number)[] & {
[dataTagSymbol]: Invoice;
[dataTagErrorSymbol]: Error;
};
}
invoiceOptions
(
id: number
id
: number) {
return
queryOptions<Invoice, Error, Invoice, (string | number)[]>(options: UnusedSkipTokenOptions<Invoice, Error, Invoice, (string | number)[]>): OmitKeyof<UseQueryOptions<Invoice, Error, Invoice, (string | number)[]>, "queryFn"> & {
queryFn?: QueryFunction<Invoice, (string | number)[], never>;
} & {
queryKey: (string | number)[] & {
[dataTagSymbol]: Invoice;
[dataTagErrorSymbol]: Error;
};
} (+2 overloads)
queryOptions
({
queryKey: (string | number)[]
queryKey
: ['invoice',
id: number
id
],
queryFn?: QueryFunction<Invoice, (string | number)[], never>
queryFn
: () =>
function fetchInvoice(id: number): Promise<Invoice>
fetchInvoice
(
id: number
id
),
})
}
const
const invoiceQuery: UseQueryResult<string, Error>
invoiceQuery
=
useQuery<Invoice, Error, string, (string | number)[]>(options: UndefinedInitialDataOptions<Invoice, Error, string, (string | number)[]>, queryClient?: QueryClient): UseQueryResult<string, Error> (+2 overloads)
useQuery
({
...
function invoiceOptions(id: number): OmitKeyof<UseQueryOptions<Invoice, Error, Invoice, (string | number)[]>, "queryFn"> & {
queryFn?: QueryFunction<Invoice, (string | number)[], never>;
} & {
queryKey: (string | number)[] & {
[dataTagSymbol]: Invoice;
[dataTagErrorSymbol]: Error;
};
}
invoiceOptions
(1),
throwOnError?: ThrowOnError<Invoice, Error, Invoice, (string | number)[]>
throwOnError
: true,
select?: (data: Invoice) => string
select
: (
invoice: Invoice
invoice
) =>
invoice: Invoice
invoice
.
createdAt: string
createdAt
,
})
const invoiceQuery: UseQueryResult<string, Error>
invoiceQuery
.data
data: string | undefined

And this just works! With all options, full type inference, looks like JavaScript, absolutely straightforward. You can of course still create custom hooks if you want to, but they should likely be built on top of queryOptions, as that’s the first abstraction building block you should be reaching for. Simplicity is king, and it doesn’t get any simpler than this. 👑


That’s it for today. Feel free to reach out to me on bluesky (opens in a new window) if you have any questions, or just leave a comment below. ⬇️

Like the monospace font in the code blocks?

Check out monolisa.dev

Bytes - the JavaScript Newsletter that doesn't suck