queryFn — центральный элемент любого запроса в TanStack
Query. Именно эта функция отвечает за получение данных, обработку
параметров запроса, генерацию ошибок и возврат результата в кеш Query
Client.
В TypeScript строгая типизация queryFn особенно важна по
нескольким причинам:
dataqueryKeyselect,
placeholderData, initialDataБез строгой типизации queryFn TanStack Query быстро
превращается в набор unknown, any и
небезопасных преобразований.
type User = {
id: number
name: string
email: string
}
const fetchUsers = async (): Promise<User[]> => {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('Failed to fetch users')
}
return response.json()
}
Использование:
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
TypeScript автоматически выводит:
query.data // User[] | undefined
Плохой пример:
const fetchUsers = async (): Promise<any> => {
const response = await fetch('/api/users')
return response.json()
}
Последствия:
query.data.foo.bar.baz
TypeScript не выдаст ошибку.
Проблемы:
selectЛучший подход:
const fetchPosts = async (): Promise<Post[]> => {
const response = await fetch('/api/posts')
if (!response.ok) {
throw new Error('Request failed')
}
return response.json()
}
Худший подход:
const fetchPosts = async () => {
return await fetch('/api/posts').then(r => r.json())
}
Во втором случае TypeScript часто выводит:
Promise<any>
Метод:
response.json()
имеет тип:
Promise<any>
Поэтому тип обязательно задаётся вручную.
const fetchUser = async (): Promise<User> => {
const response = await fetch('/api/user')
if (!response.ok) {
throw new Error('Failed')
}
const data: User = await response.json()
return data
}
Очень распространённый паттерн:
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error('Request failed')
}
return response.json() as Promise<T>
}
Использование:
const fetchUsers = () => {
return fetchJson<User[]>('/api/users')
}
TanStack Query передаёт в queryFn специальный
контекст:
type QueryFunctionContext = {
queryKey: QueryKey
signal: AbortSignal
meta: QueryMeta | undefined
pageParam?: unknown
}
const fetchUser = async ({ queryKey }: QueryFunctionContext) => {
const [, userId] = queryKey
const response = await fetch(`/api/users/${userId}`)
return response.json()
}
Проблемы:
userId имеет тип unknowntype UserQueryKey = ['user', number]
const fetchUser = async (
context: QueryFunctionContext<UserQueryKey>
): Promise<User> => {
const [, userId] = context.queryKey
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error('Failed')
}
return response.json()
}
Теперь:
userId // number
useQuery<User>({
queryKey: ['user', 15],
queryFn: async (): Promise<User> => {
const response = await fetch('/api/user/15')
return response.json()
},
})
Подход работает, но имеет недостатки:
TanStack Query умеет автоматически выводить тип данных из
queryFn.
const fetchTodos = async (): Promise<Todo[]> => {
return fetchJson('/api/todos')
}
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
TypeScript автоматически выводит:
query.data // Todo[] | undefined
const fetchData = async (): Promise<User[] | null> => {
return fetchJson('/api/users')
}
Теперь:
query.data // User[] | null | undefined
Появляется тройная неопределённость:
undefinednullВ TanStack Query обычно лучше:
Promise<User[]>
а не:
Promise<User[] | null>
Поскольку библиотека уже использует undefined как
состояние отсутствующих данных.
const fetchProducts = async (): Promise<Product[]> => {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error('Products request failed')
}
return response.json()
}
class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
const fetchUsers = async (): Promise<User[]> => {
const response = await fetch('/api/users')
if (!response.ok) {
throw new ApiError(
'Failed to fetch users',
response.status
)
}
return response.json()
}
const query = useQuery<User[], ApiError>({
queryKey: ['users'],
queryFn: fetchUsers,
})
Теперь:
query.error?.status
типизирован корректно.
import axios from 'axios'
const fetchUsers = async (): Promise<User[]> => {
const response = await axios.get<User[]>('/users')
return response.data
}
import axios, { AxiosError } from 'axios'
type ApiValidationError = {
message: string
fields: Record<string, string[]>
}
const query = useQuery<
User[],
AxiosError<ApiValidationError>
>({
queryKey: ['users'],
queryFn: fetchUsers,
})
type UserDetailsKey = ['user-details', number]
const fetchUserDetails = async (
context: QueryFunctionContext<UserDetailsKey>
): Promise<UserDetails> => {
const [, userId] = context.queryKey
return fetchJson<UserDetails>(
`/api/users/${userId}`
)
}
export const userKeys = {
all: ['users'] as const,
detail: (id: number) =>
['users', id] as const,
}
useQuery({
queryKey: userKeys.detail(10),
queryFn: fetchUser,
})
type UserDetailKey =
ReturnType<typeof userKeys.detail>
const fetchUser = async (
context: QueryFunctionContext<UserDetailKey>
): Promise<User> => {
const [, id] = context.queryKey
return fetchJson(`/api/users/${id}`)
}
['users', filters]
где:
filters?: UserFilters
Теперь ключ может содержать:
undefined
type UsersKey = [
'users',
{
role?: string
active?: boolean
}
]
useQuery({
queryKey: [
'users',
{
role: 'admin',
active: true,
},
],
queryFn: fetchUsers,
})
TanStack Query автоматически отменяет запросы через
AbortController.
const fetchUsers = async ({
signal,
}: QueryFunctionContext): Promise<User[]> => {
const response = await fetch('/api/users', {
signal,
})
return response.json()
}
try {
const response = await fetch('/api/users', {
signal,
})
return response.json()
} catch (error) {
if (error instanceof DOMException) {
if (error.name === 'AbortError') {
throw error
}
}
throw error
}
type ProjectsResponse = {
items: Project[]
nextPage?: number
}
const fetchProjects = async ({
pageParam = 1,
}: QueryFunctionContext): Promise<ProjectsResponse> => {
return fetchJson(
`/api/projects?page=${pageParam}`
)
}
const fetchProjects = async ({
pageParam = 1,
}: QueryFunctionContext<
['projects'],
number
>): Promise<ProjectsResponse> => {
return fetchJson(
`/api/projects?page=${pageParam}`
)
}
Теперь:
pageParam // number
type User = {
id: number
firstName: string
lastName: string
}
const query = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) =>
users.map(user => ({
...user,
fullName:
`${user.firstName} ${user.lastName}`,
})),
})
TypeScript автоматически выводит новый тип данных.
Если queryFn возвращает any:
select: (users) => users.foo.bar
ошибка не появится.
const userId: number | undefined = 10
queryFn: () => fetchUser(userId)
TypeScript выдаёт ошибку:
number | undefined
useQuery({
queryKey: ['user', userId],
enabled: !!userId,
queryFn: () => {
if (!userId) {
throw new Error('Missing userId')
}
return fetchUser(userId)
},
})
function userQueryOptions(id: number) {
return queryOptions({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
})
}
useQuery(userQueryOptions(5))
TypeScript сохраняет строгую типизацию.
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
Типы автоматически сохраняются между:
placeholderData: []
Если queryFn возвращает:
User[]
всё корректно.
Но если:
User[] | null
возникают конфликтующие типы.
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
initialData: [],
})
Тип initialData обязан соответствовать возвращаемому
типу queryFn.
const fetchUsers = async (): Promise<User[]> => {
const response = await fetch('/api/users')
const users: ApiUser[] =
await response.json()
return users.map(user => ({
id: user.id,
name: user.full_name,
email: user.email_address,
}))
}
Очень полезный подход:
type UserDto = {
user_id: number
user_name: string
}
type User = {
id: number
name: string
}
const fetchUsers = async (): Promise<User[]> => {
const dto = await fetchJson<UserDto[]>(
'/api/users'
)
return dto.map(user => ({
id: user.user_id,
name: user.user_name,
}))
}
Плохой пример:
const fetchUsers = async (): Promise<unknown> => {
return fetchJson('/api/users')
}
Теперь весь downstream-код ломается:
query.data?.map(...)
TypeScript запрещает операции.
TypeScript не проверяет реальные данные сервера.
Поэтому для критически важных API часто используется:
import { z } fr om 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
})
type User = z.infer<typeof UserSchema>
const fetchUser = async (): Promise<User> => {
const response = await fetch('/api/user')
const json = await response.json()
return UserSchema.parse(json)
}
Теперь:
Promise<User[]>
а не:
Promise<any>
Особенно:
response.json() as anyPromise<any>queryFn: async () => anytype UserKey = ['user', number]
QueryFunctionContext<UserKey>
fetchJson<T>()
Это резко повышает стабильность frontend-кода.
Особенно:
Крупные проекты обычно строят структуру:
api/
client.ts
users.ts
posts.ts
models/
user.ts
post.ts
queries/
users/
keys.ts
queries.ts
mutations.ts
export const userKeys = {
all: ['users'] as const,
detail: (id: number) =>
['users', id] as const,
}
export async function fetchJson<T>(
url: string
): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error('Request failed')
}
return response.json()
}
type User = {
id: number
name: string
}
type UserKey =
ReturnType<typeof userKeys.detail>
export const fetchUser = async (
context: QueryFunctionContext<UserKey>
): Promise<User> => {
const [, id] = context.queryKey
return fetchJson<User>(
`/api/users/${id}`
)
}
const userQuery = useQuery({
queryKey: userKeys.detail(5),
queryFn: fetchUser,
})
userQuery.data?.name
userQuery.error
В результате получается: