createApi: endpoints, consultas y mutaciones
El nivel abre por la puerta que lo sostiene todo: la declaración única de una API. Esta lección disecciona createApi como lo que realmente es —un compilador de endpoints a maquinaria de cache— y recorre sus cuatro piezas obligatorias: reducerPath como direccion del subarbol de cache, baseQuery como transporte intercambiable, tagTypes como vocabulario cerrado de invalidación y el mapa de endpoints construido por builder. Distingue con precisión la firma de builder.query y builder.mutation, sus dos parámetros de tipo —resultado y argumento— y explica por qué una consulta se suscribe y una mutación se dispara. Cierra con el mecanismo de generación de hooks: la convención de nombres, la tupla que devuelve una mutación, y la tesis de que declarar endpoints es describir un contrato, no escribir código de red.
En el Nivel 3 conociste RTK Query como el final lógico de escribir thunks a mano: si repites el trío de estados por cada lectura, estás construyendo una cache sin admitirlo. Este nivel vuelve sobre esa herramienta para agotarla, y empieza donde empieza todo: createApi. La tentación es leerlo como una función de configuración, un objeto con opciones que devuelve unos hooks cómodos. Es mucho más que eso. createApi es un pequeño compilador: toma una descripción declarativa de endpoints —qué pides, qué devuelve, qué invalida— y emite un reducer, un middleware, un conjunto de selectores, un normalizador de argumentos y una familia de hooks de React tipados uno a uno. Nada de eso lo escribes tú, y esa asimetría entre lo poco que declaras y lo mucho que obtienes solo se entiende si comprendes qué está generando y por qué puede permitírselo.
- Descomponer
createApien sus cuatro piezas:reducerPath,baseQuery,tagTypesy el mapa de endpoints. - Distinguir la firma y la semántica de
builder.queryfrente abuilder.mutation. - Leer el objeto de una consulta y la tupla de una mutación como dos contratos distintos con la UI.
- Entender la generación de hooks como una derivación mecánica del nombre del endpoint.
La declaración única y sus cuatro piezas
Un createApi describe una superficie de comunicación completa con un backend en un único artefacto. Sus cuatro piezas no son opciones equivalentes: cada una responde a una pregunta distinta. reducerPath decide bajo qué clave del store vivirá el subárbol de cache, y por eso debe registrarse en configureStore con esa misma clave calculada, nunca con una cadena escrita a mano en dos sitios. baseQuery es el transporte: una función que recibe un argumento de endpoint y devuelve datos o error, con fetchBaseQuery como implementación estándar sobre fetch, pero perfectamente sustituible por una que hable GraphQL, axios o un cliente propio. tagTypes declara por adelantado el vocabulario cerrado de etiquetas que la API puede usar, y su existencia no es burocracia: convierte errores de invalidación en errores de tipo. El mapa de endpoints es el resto.
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
interface Post { id: string; titulo: string; autorId: string }
interface NuevoPost { titulo: string }
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Post", "Autor"],
endpoints: (builder) => ({
listarPosts: builder.query<Post[], void>({
query: () => "posts",
}),
obtenerPost: builder.query<Post, string>({
query: (id) => `posts/${id}`,
}),
crearPost: builder.mutation<Post, NuevoPost>({
query: (cuerpo) => ({ url: "posts", method: "POST", body: cuerpo }),
}),
}),
});
Obsérvese que endpoints no es un objeto sino una función que recibe builder. Esa indirección es deliberada y tiene una razón puramente tipológica: builder llega ya parametrizado con los tagTypes declarados y con el tipo de error del baseQuery, de modo que dentro de cada endpoint TypeScript sabe qué etiquetas son legales y qué forma tiene un fallo. Si endpoints fuera un objeto plano, esa información tendría que repetirse endpoint a endpoint. La función es el canal por el que el contexto de la API entra en cada definición.
Un error frecuente de quien viene de organizar código por dominios es crear un createApi por cada recurso: uno para posts, otro para autores, otro para comentarios. Resiste ese impulso. Las etiquetas solo pueden invalidar dentro de la misma API, así que fragmentar por recurso rompe justamente el grafo de invalidación que el nivel siguiente construye: una mutación de autor no podrá refrescar una consulta de posts si viven en APIs distintas. La unidad natural de un createApi es el backend, no la entidad. Si el fichero crece, la respuesta es injectEndpoints, que añade endpoints a una API existente desde otro módulo conservando un único espacio de cache y de etiquetas.
Consultas: suscripción, no llamada
builder.query toma dos parámetros de tipo en orden fijo: primero el resultado, después el argumento. Así, builder.query<Post, string> significa una consulta que recibe un identificador de tipo cadena y produce un Post. Cuando el endpoint no necesita argumento se escribe void, y el hook se invoca sin parámetros. Este orden —resultado antes que argumento— es el inverso del habitual en firmas de función y conviene memorizarlo, porque invertirlo produce errores de tipo cuyo mensaje señala un lugar distinto del error real.
La diferencia conceptual con una llamada a fetch es más profunda que la ergonomía. Un hook de consulta no ejecuta una petición: declara una suscripción a una entrada de cache identificada por el par de endpoint y argumentos serializados. Que se dispare o no una petición HTTP es una consecuencia del estado de esa entrada, no del hecho de haber llamado al hook. Si otro componente ya está suscrito con los mismos argumentos, no hay segunda petición; si la entrada existe y sigue vigente, tampoco. Y al desmontarse el último suscriptor, la entrada no se borra: entra en un periodo de gracia gobernado por keepUnusedDataFor durante el cual una nueva suscripción la reutiliza al instante.
function DetallePost({ id }: { id: string }) {
const { data, isLoading, isFetching, isError, refetch } =
useObtenerPostQuery(id, { skip: id === "" });
if (isLoading) return <p>cargando</p>;
if (isError) return <button onClick={refetch}>reintentar</button>;
return (
<article aria-busy={isFetching}>
<h2>{data?.titulo}</h2>
</article>
);
}
La opción skip merece atención porque resuelve una tensión estructural: los hooks no pueden invocarse condicionalmente, pero muchas consultas dependen de un dato que aún no existe. skip mantiene la llamada al hook en su sitio y suspende la suscripción, devolviendo un estado uninitialized sin datos ni error. Su gemela selectFromResult opera en el otro extremo, filtrando lo que el componente observa para que solo se re-renderice cuando cambie la porción que le interesa.
builder.query
Lectura. Declara una suscripción a una entrada de cache. Se activa al montar, deduplica por argumentos y devuelve un objeto de estado.
builder.mutation
Escritura. No se suscribe a nada: devuelve un disparador que ejecutas tú, con su propio estado de progreso asociado.
Mutaciones: el disparador y la promesa
Una mutación invierte el contrato. Su hook no devuelve un objeto de estado sino una tupla de dos elementos: el disparador y el estado de la última ejecución. La razón es que una escritura no es reactiva a nada —no hay entrada de cache a la que suscribirse porque el resultado aún no existe— y por tanto necesita un momento explícito de ejecución que el usuario controla. El disparador devuelve un objeto especial: es una promesa del resultado, pero además expone unwrap para propagar el error como excepción y abort para cancelar la petición en vuelo.
function FormularioPost() {
const [crear, { isLoading, isSuccess, reset }] = useCrearPostMutation();
async function enviar(titulo: string) {
try {
const creado = await crear({ titulo }).unwrap();
console.log("creado con id", creado.id);
} catch {
console.warn("el servidor rechazo la escritura");
}
}
return <button disabled={isLoading} onClick={() => enviar("nuevo")}>guardar</button>;
}
Que el disparador exponga abort completa la simetría con las consultas, que se cancelan solas al desmontarse el último suscriptor. Una escritura no puede cancelarse sola porque nadie está suscrito a ella, así que la cancelación tiene que ser explícita: guardar el objeto devuelto por el disparador y llamar a abort cuando el componente se desmonta o cuando el usuario cierra el diálogo. Omitirlo no rompe nada grave, pero deja peticiones en vuelo cuyo resultado ya no interesa a nadie.
Sin unwrap, el disparador nunca rechaza: resuelve siempre con un objeto que contiene data o error, y un await desnudo hará creer al código que todo fue bien. Con unwrap, el fallo se convierte en una excepción capturable y el flujo recupera la forma familiar de try y catch. Elegir entre ambos no es estilístico: determina si tu manejo de errores es exhaustivo o silencioso.
Cada llamada a un hook de mutación crea una instancia independiente con su propio estado de progreso. Dos componentes que invoquen useCrearPostMutation no comparten isLoading ni data, aunque disparen sobre el mismo endpoint. Esto sorprende a quien espera la simetría con las consultas, donde compartir es la norma. Si necesitas que varias vistas observen la misma escritura en curso, la opción fixedCacheKey liga esas instancias bajo una clave común; sin ella, el spinner aparecerá solo en el componente que disparó, que casi siempre es exactamente lo que quieres.
Qué genera createApi y por qué el nombre no es azar
Lo que sale de createApi es un objeto con más superficie de la que suele usarse. Están el reducer y el middleware que se registran en el store, los hooks generados, un endpoints con acceso programático a cada uno —initiate para lanzarlo fuera de React, select para leer su entrada de cache desde un selector— y un util con las operaciones de bajo nivel sobre la cache que las lecciones tercera y cuarta explotarán.
flowchart TD A[mapa declarativo de endpoints] --> B[objeto api] B --> C[api.reducer bajo reducerPath] B --> D[api.middleware del ciclo de vida] B --> E[hooks generados por nombre] B --> F[api.util para cache y parches] E --> G[useObtenerPostQuery] E --> H[useCrearPostMutation] style B fill:#89b4fa,color:#11111b style E fill:#a6e3a1,color:#11111b
De ese inventario, el par initiate y select es el que más veces resuelve problemas que parecían no tener solución. initiate permite lanzar una consulta desde fuera del árbol de React —un middleware de escucha, un cargador de ruta, un manejador de notificación push— devolviendo una promesa y una función de suscripción manual que debes liberar tú. select devuelve un selector estándar del store, y por tanto compone con createSelector como cualquier otro, lo cual convierte una entrada de cache en un ciudadano de primera del mundo de Redux en lugar de un enclave aparte. Ambas se apoyan en la misma propiedad: como la cache vive dentro del store, todo lo que sabes hacer con el store sirve también aquí.
La convención de nombres es puramente mecánica: prefijo use, nombre del endpoint capitalizado, sufijo Query o Mutation. Que sea mecánica es lo que permite a TypeScript derivar los tipos exactos de cada hook desde la definición del endpoint sin ninguna anotación adicional; el nombre no es documentación, es la clave de una tabla que el sistema de tipos recorre. De ahí una consecuencia práctica: renombrar un endpoint renombra su hook y rompe todos los sitios donde se usaba, lo cual es exactamente el comportamiento deseable.
Lo que separa createApi de cualquier envoltorio de fetch no es la comodidad de los hooks: es que has dejado de escribir código imperativo de red para escribir una descripción del contrato entre tu cliente y su backend. La distinción parece retórica hasta que se observan sus consecuencias. Un código imperativo de red solo puede ser ejecutado; una descripción declarativa puede ser inspeccionada, transformada y explotada por una máquina que sepa leerla. Y RTK Query la lee con avidez: de la firma deduce los tipos de cada hook sin que anotes nada; del argumento deduce la clave de cache y con ella la deduplicación; de las etiquetas deducirá el grafo de invalidación de la próxima lección; de la estructura del endpoint deduce cuándo prefetchear, cuándo reintentar, cuándo caducar. Ninguna de esas capacidades fue programada por ti, y ninguna podría haberse añadido a un código que se limitara a llamar a fetch dentro de un efecto, porque un cuerpo de función es opaco: nadie puede razonar sobre lo que hace sin ejecutarlo. Esa es la lección que se generaliza mucho más allá de esta librería. La diferencia entre una herramienta que te ahorra teclas y una que cambia lo que es posible está en si te obliga a describir tu intención en un formato que otro programa pueda analizar. Redux ya lo hizo una vez, obligándote a expresar cada cambio como una acción serializable en lugar de una mutación arbitraria, y a cambio te dio DevTools, time-travel, persistencia y replay —capacidades imposibles sobre mutaciones opacas—. createApi repite el gesto un nivel más arriba: te obliga a describir tu red como datos, y a cambio te devuelve una cache que razona sola. Cuando en el resto del nivel veas aparecer la invalidación automática, el prefetch o las actualizaciones optimistas, recuerda que ninguna es magia añadida: todas son cosechas de haber declarado en lugar de haber ejecutado.
- Toma un módulo real donde llamas a
fetchdentro de efectos y enumera cada llamada como un endpoint: nombre, argumento, resultado. No escribas código todavía; escribe la tabla del contrato. - Traduce esa tabla a un único
createApicon sureducerPath, subaseQueryy sustagTypesdeclarados aunque aún no los uses. - Tipa cada endpoint con los dos parámetros en el orden correcto y comprueba que ningún hook necesita una anotación manual en el consumo.
- Sustituye una consulta condicional escrita con un
ifantes del efecto por la misma consulta conskip, y observa el estadouninitialized. - Convierte una escritura a
unwrapdentro de un bloque de captura y comprueba que el error del servidor ahora sí interrumpe el flujo optimista que antes seguía adelante. - Divide el fichero en dos usando
injectEndpointsy verifica en las DevTools que ambos módulos comparten un único subárbol de cache.