React Query y TypeScript
Este artículo es una traducción del post "React Query and TypeScript" publicado por Dominik en su blog TkDodo.eu
TypeScript está 🔥 on fire. Parece que en esto estamos de acuerdo dentro de la comunidad frontend. Muchísimos desarrolladores ya esperan que una librería esté escrita en TypeScript o al menos provea definiciones de tipos.
Para mí, si una librería está escrita en TypeScript, las definiciones de tipos son los mejores docs que existen. Nunca estarán incorrectos porque reflejan directamente la implementación. Muchas veces inspecciono los tipos antes de leer los docs de la librería.
React Query fue inicialmente escrita en JavaScript (v1), y después fue reescrita en TypeScript para la v2. Esto quiere decir que ahora ya existe muy buen soporte para los usuarios de TypeScript.
Hay, de todas formas, un par de gotchas o sorpresas al trabajar con TypeScript debido a lo dinámico e inopinado que es React Query. Vamos a verlos de uno en uno para mejorar tu experiencia todavía más.
Genéricos (Generics)
React Query hace un uso extenso de genéricos. Esto es necesario porque la librería no solicita data por ti, así que no puede saber qué tipo
tendrá la data devuelta por tu backend/API.
La sección sobre TypeScript en los docs oficiales no es muy extensa, y nos dice que debemos especificar explícitamente los Genéricos que useQuery
esperará cuando lo llames:
function useGroups() {
return useQuery<Group[], Error>('groups', fetchGroups);
}
Con el paso del tiempo React Query ha añadido más Genéricos al hook useQuery
(ahora existen cuatro), mayormente porque se ha añadido nueva funcionalidad.
El código anterior funciona, y se asegurará de que la propiedad data
de nuestro hook personalizado tiene el tipo correcto (Group[] | undefined
), además de que nuestro error
sea del tipo Error | undefined
.
Pero no funcionará así para casos más avanzados, especialmente cuando se necesiten los otros dos Genéricos.
Los cuatro Genéricos
Esta es la definición actual del hook useQuery
:
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
Hay muchas cosas juntas aquí, así que vamos a verlo uno por uno:
TQueryFnData
: el tipo devuelto por la funciónqueryFn
. En el ejemplo anterior esGroup[]
.TError
: el tipo de los errores que esperar dequeryFn
.Error
en el ejemplo.TData
: el tipo que nuestra data tendrá finalmente. Solo es relevante si usas la opciónselect
, ya que entonces la propiedaddata
puede ser diferente de la que devolvería laqueryFn
. Sino, será por defecto lo quequeryFn
devuelva.TQueryKey
: el tipo de laqueryKey
, solo relevante si usas laqueryKey
que se pasa a tuqueryFn
.
Como puedes ver, todos esos Genéricos tienen valores predeterminados, lo que significa que si no provees unos, TypeScript recurrirá a esos tipos.
Esto funciona muy parecido a los parámetros por defecto en JavaScript:
// si no se provee "b", será "2"
function multiply(a, b = 2) {
return a * b;
}
multiply(10, 5) // devuelve 50
multiply(10) // devuelve 20
Inferencia de tipos
TypeScript funciona mejor si le dejas inferir (o averiguar) por sí mismo qué tipo debería tener cada cosa. No solo hace que tu código sea más fácil de escribir (no tienes que poner todos los tipos), sino también que sea más fácil de leer. En muchos casos hará que tu código tenga el mismo aspecto que si fuera JavaScript.
Dos ejemplos muy simples de inferencia de tipos:
// 🟢 num es de tipo "number"
const num = Math.random() + 5;
// 🟢 tanto "greeting" como el resultado de "greet" serán "string"
function greet(greeting = 'ciao') {
return `${greeting}, ${getName()}`
}
Respecto a los Genéricos, también pueden ser parcialmente inferidos desde su uso, lo que es súper genial. Podrías proveerlos manualmente, pero en muchos casos no es necesario:
// función que devuelve el mismo valor y acepta Genéricos
function identity<T>(value: T): T {
return value;
}
// 🟡 no haría falta pasar el Genérico
let result = identity<number>(23)
// 🟡 no haría falta anotar el resultado
let result: number = identity(23)
// 🟢 inferirá correctamente que "result" es tipo "string"
let result = identity('react-query-string-text');
La Inferencia Parcial de Argumentos de Tipo
…no existe en TypeScript todavía (mira esta issue abierta). Esto significa que si provees un Genérico, tienes que proveerlos todos.
Pero como React Query usa valores predeterminados en su Genéricos, podemos no darnos cuenta de esto. Los mensajes de error devueltos pueden ser un poco crípticos.
Veamos un ejemplo donde esto es contraproducente:
function useGroupCount() {
return useQuery<Group[], Error>('groups', fetchGroups, {
select: (groups) => groups.length,
// 🔴 Type '(groups: Group[]) => number' is not assignable to type '(data: Group[]) => Group[]'.
// 🔴 Type 'number' is not assignable to type 'Group[]'.ts(2322)
});
}
Como no hemos pasado un tercer Genérico, se carga el valor por defecto, que es también Group[]
, pero en realidad devolvemos un number
desde nuestra función select
.
Una solución es simplemente añadir el tercer Genérico:
function useGroupCount() {
// 🟢 solucionado
return useQuery<Group[], Error, number>('groups', fetchGroups, {
select: (groups) => groups.length,
});
}
Mienstras no tengamos Inferencia Parcial de Argumentos de Tipo, hay que usar lo que podamos.
Así que, ¿cuál es la alternativa?
Inferirlo todo
Empecemos por no pasar ningún Genérico y dejar que TypeScript decida qué hacer. Para que esto funcione necesitamos que la función queryFn
devuelva un buen tipo. Por supuesto, si agregas tu función en-línea sin un tipo explícito para su resultado, tendrás any
, porque eso es lo que axios
o fetch
te darán:
function useGroups() {
// 🔴 data será "any" en este caso
return useQuery('groups', () =>
axios.get('groups').then((response) => response.data) // queryFn en-línea
);
}
Si te gusta, como a mí, mantener tu capa de API separada de tus solicitudes, tendrás que añadir definiciones de tipo para evitar any
implícito, así que React Query puede inferir el resto:
// extraemos y anotamos la queryFn
function fetchGroups(): Promise<Group[]> {
return axios.get('groups').then((response) => response.data);
}
// 🟢 "data" será "Group[] | undefined" en este caso
function useGroups() {
return useQuery('groups', fetchGroups);
}
// 🟢 "data" será "number | undefined" en este caso
function useGroupCount() {
return useQuery('groups', fetchGroups, {
select: (groups) => groups.length,
});
}
Las ventajas de este sistema son:
- Ya no hay que especificar los Genéricos manualmente.
- Funciona en casos donde el tercer Genérico (
select
) y el cuarto (queryKey
) era requeridos. - Seguirá funcionando si se añaden más Genéricos a React Query.
- El código es menos confuso y se parece más a JavaScript.
¿Y qué hay de error
?
Por defecto, sin ningún Genérico, el tipo de error
será inferido a unknown
.
Esto podría sonarte como un bug, ¿por qué no es Error
? Esto se hace a propósito, porque en JavaScript puedes devolver cualquier cosa desde throw
, no tiene por qué ser de tipo Error
:
// todos son totalmente válidos
throw 5;
throw undefined;
throw Symbol('foo');
Como React Query no se encarga de la función que devuelve la Promesa, tampoco puede saber qué tipo
de errores producirá. Así que unknown
es lo correcto.
Una vez que TypeScript permita saltarse algunos Genéricos al llamar a una función con múltiples Genéricos (mira esta issue para más información) podremos controlar esto mejor. Pero por ahora, si necesitamos manejar errores y no queremos pasar Genéricos, podemos reducir el tipo con una comprobación instanceof
:
const groups = useGroups();
if (groups.error) {
// 🔴 esto no funciona porque: Object is of type 'unknown'.ts(2571)
return <div>An error occurred: {groups.error.message}</div>;
}
// 🟢 el check "instanceof" reduce a tipo `Error`
if (groups.error instanceof Error) {
return <div>An error occurred: {groups.error.message}</div>
}
Como de todas formas tenemos que comprobar si existe un error, el check instanceof
parece una buena idea, y además se asegurará de que nuestro error
tiene una propiedad message
.
Esto va en línea con TypeScript, ya que desde la versión 4.4 tenemos el indicador useUnknownInCatchVariables
(según esto), con el que las variables catch
son unknown
en lugar de any
como anteriormente.
Narrowing de tipos
Es probable que no uses la desestructuración con React Query. Primero porque nombres como data
y error
son muy genéricos (a propósito), así que de todas formas los renombrarás. Mantener todo el objeto será lo que dé contexto a qué data
es, o de dónde viene el error
.
Además, antes de TypeScript 4.6, ayudaba a TypeScript a ajustar los tipos cuando usabas uno de los indicadores booleanos de estado, algo que no podías conseguir si usabas la desestructuración:
// solicitud
const { data, isSuccess } = useGroups();
if (isSuccess) {
// 🔴 aquí "data" todavía será de tipo `Group[] | undefined`
}
// solicitud sin desestructurar resultado
const groupsQuery = useGroups();
if (groupsQuery.isSuccess) {
// 🟢 "groupsQuery.data" será de tipo `Group[]`
}
Esto no tenía nada que ver con React Query, es como TypeScript funcionaba. En este tweet de @danvdk hablan más del tema.
Actualizado: Como te decía, TypeScript 4.6 añadió control flow analysis for destructured discriminated unions, lo que hace que ambos ejemplos del código superior funcionen. Así que esto ya no es un problema.
Tipos con la opción enabled
Como vimos en la parte 1, la opción enabled
es muy potente, pero puede ser un poco confuso usarla a nivel de tipos si quieres emplearla para Solicitudes Dependientes y desactivar tu solicitud mientras algunos parmámetros no estén definidos:
// queryFn
function fetchGroup(id: number): Promise<Group> {
return axios.get(`group/${id}`).then((response) => response.data);
}
// solicitud
function useGroup(id: number | undefined) {
return useQuery(['group', id], () => fetchGroup(id), {
// activar la solicitud solo si existe "id"
enabled: Boolean(id),
});
// 🔴 Argument of type 'number | undefined' is not assignable to parameter of type 'number'.
// 🔴 Type 'undefined' is not assignable to type 'number'.ts(2345)
}
Técnicamente, TypeScript está en lo cierto, id
es posiblemente undefined
: la opción enabled
no hace ningún Narrowing de tipos.
Además existen formas de sobrepasar la opción enabled
, por ejemplo llamando al método refetch
devuelvo por useQuery
. En ese caso id
podría ser undefined
.
El mejor sistema, si no te gusta el non-null assertion operator, es aceptar que id
puede ser undefined
y rechazar la promesa en la queryFn
. Añade algo de duplicación, pero también es explícito y seguro:
// queryFn
function fetchGroup(id: number | undefined): Promise<Group> {
// 🟢 comprobar "id" al inicio porque también puede ser `undefined`
return typeof id === 'undefined'
? Promise.reject(new Error('Invalid id'))
: axios.get(`group/${id}`).then((response) => response.data);
}
// solicitud
function useGroup(id: number | undefined) {
return useQuery(['group', id], () => fetchGroup(id), {
enabled: Boolean(id),
})
}
Actualizaciones optimistas
Actualización: Desde TypeScript 4.7 se añadió Improved Function Inference in Objects and Methods, lo que soluciona el problema que te cuento a continuación. Las actualizaciones optimistas deberían inferir correctametne el tipo sin trabajo extra.
Conseguir que las actualizaciones optimistas funcionen bien con TypeScript no es algo sencillo, así que tienes un extenso ejemplo en los docs.
La parte importante es: Tienes que definir explícitamente el tipo del argumento variables
que pasas a onMutate
para conseguir la mejor inferencia posible de tipos. Este comentario también tiene más información.
useInfiniteQuery
En general, agregar tipos a useInfiniteQuery
no es diferente de useQuery
. Un problema mencionable es que el valor pageParam
, que es pasado a la función queryFn
, tiene el tipo any
. Podría mejorarse en la librería, pero mientras sea any
, probablemente es mejor anotarlo explícitamente:
type GroupResponse = { next?: number, groups: Group[] }
const queryInfo = useInfiniteQuery(
['groups'],
// 🟢 tipo explícito para sobreescribir `any`
({ pageParam = 0 }: { pageParam: GroupResponse['next']) =>
fetchGroups(groups, pageParam),
{
getNextPageParam: (lastGroup) => lastGroup.next,
}
)
Si fetchGroups
devuelve una respuesta de tipo GroupResponse
, lastGroup
tendrá su tipo inferido correctamente, y podemos usar el mismo tipo para anotar pageParam
.
Tipo de la queryFn
predeterminada
Muchos usuarios de React Query utilizan una defaultQueryFn
, una queryFn
predeterminada. Es una buena forma de aprovechar la queryKey
para construir la url de solicitud. Si agregas la función al crear el queryClient
, el tipo de QueryFunctionContext
también será adivinado:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: async ({ queryKey: [url] }) => {
const { data } = await axios.get(`${baseUrl}/${url}`);
return data;
},
},
},
});
Esto funciona, pero url
será inferido con el tipo unknown
, porque queryKey
es de tipo unknown[]
:
En el momento en que se crea el queryClient
no hay ninguna garantía de cómo se construirán las queryKey
s al llamar a useQuery
, así que no hay mucho que React Query pueda hacer. Esto ocurre por la naturaleza dinámica de esta funcionalidad.
No es algo negativo por si mismo, y solo quiere decir que tendrás que trabajar defensivamente y ajustar el tipo con comprobaciones al trabajar con ello, por ejemplo:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: async ({ queryKey: [url] }) => {
// 🟢 ajustar el tipo de "url" a "string" para poder usarlo
if (typeof url === 'string') {
const { data } = await axios.get(`${baseUrl}/${url.toLowerCase()}`);
return data;
}
throw new Error('Invalid QueryKey');
},
},
},
});
Esto ilustra muy bien por qué unknown
es un gran tipo (y poco usado) en comparación a any
… aunque eso es un tema para otro post ;)
La serie completa
Este post es parte de la serie React-Query por Tkdodo que he traducido desde su blog. Mira todos los artículos:
- Consejos prácticos sobre React Query
- Transformación de data en React Query
- Optimización del renderizado en React Query
- Comprobar el estado en React Query
- Tests en React Query
- React Query y TypeScript
- Usar Websockets con React Query
- Claves eficaces en React Query
- Data inicial y de ejemplo en React Query
- React Query como un Gestor de Estado
No hay sección de comentarios, pero me encantaría escuchar tu opinión: escríbeme en Twitter y cuéntame!