Transformaciones: transformResponse y datos derivados
Entre lo que el backend devuelve y lo que la UI necesita hay casi siempre una distancia, y esta lección trata de dónde colocar el puente. Presenta transformResponse como la frontera anticorrupción de la aplicación, el único punto donde la forma ajena se convierte en forma propia, y su gemela transformErrorResponse para los fallos. Sigue con la normalización dentro de la cache usando createEntityAdapter, que convierte un array de entidades en un diccionario indexado con orden explícito, y explica por qué eso importa incluso cuando RTK Query ya cachea. Analiza después selectFromResult y la construcción de selectores memoizados sobre entradas de cache con endpoints.select, distinguiendo qué transformaciones pertenecen a la escritura de la cache y cuáles a su lectura. Cierra con la tesis de que cada dato derivado guardado es una promesa de coherencia que alguien tendrá que cumplir.
Ningún backend devuelve exactamente la forma que tu interfaz quiere. Devuelve envoltorios con metadatos, fechas como cadenas, campos con nombres heredados de una base de datos que nadie recuerda haber diseñado, y colecciones planas donde tú necesitas índices. La pregunta no es si habrá una transformación, sino dónde vivirá: repartida por los componentes que la aplican cada uno a su manera, o concentrada en una frontera única y explícita. RTK Query ofrece esa frontera en dos lugares complementarios y decidir qué va en cada uno es la competencia real de esta lección. transformResponse transforma antes de guardar, y lo que produce es lo que la cache contiene; los selectores transforman después de leer, y lo que producen es efímero. La diferencia entre ambos no es de rendimiento sino de ontología: uno decide qué es verdad en tu aplicación, el otro decide cómo mirarla.
- Usar
transformResponsecomo capa anticorrupción entre la forma del backend y el modelo del cliente. - Normalizar entidades dentro de la cache con
createEntityAdaptery consumir su estado indexado. - Construir selectores memoizados sobre entradas de cache con
endpoints.selectyselectFromResult. - Decidir si un dato derivado pertenece a la escritura de la cache, a su lectura o a ninguna de las dos.
transformResponse: la frontera anticorrupción
transformResponse se ejecuta una sola vez, entre la respuesta del baseQuery y el almacenamiento en la cache. Recibe la carga cruda, los metadatos de la respuesta —cabeceras y objeto de respuesta original, útiles para paginación— y el argumento del endpoint. Lo que devuelva es literalmente lo que quedará guardado, lo que verán los hooks, lo que recibirá providesTags y lo que parchearán las actualizaciones optimistas de la lección siguiente. Esa centralidad es la razón por la que conviene tratarlo con el respeto de una frontera arquitectónica y no como un ayudante de conveniencia.
interface PostRemoto {
post_id: number;
post_title: string;
created_at: string;
author: { author_id: number; author_name: string };
}
interface Post { id: string; titulo: string; creadoEn: number; autorId: string; autorNombre: string }
const aPost = (r: PostRemoto): Post => ({
id: String(r.post_id),
titulo: r.post_title,
creadoEn: Date.parse(r.created_at),
autorId: String(r.author.author_id),
autorNombre: r.author.author_name,
});
Con esa función, el endpoint se limita a declarar la traducción, y a partir de ese punto ningún componente vuelve a ver jamás un nombre de campo del backend. El beneficio no es estético: es que el día que el servidor renombre un campo o cambie el formato de fecha, el número de ficheros que hay que tocar es exactamente uno.
listarPosts: builder.query<Post[], void>({
query: () => "posts",
transformResponse: (crudo: { data: PostRemoto[] }) => crudo.data.map(aPost),
transformErrorResponse: (error) => ({
codigo: error.status,
mensaje: "no se pudieron cargar los posts",
}),
providesTags: (r) => [
...(r ?? []).map((p) => ({ type: "Post" as const, id: p.id })),
{ type: "Post" as const, id: "LIST" },
],
}),
transformErrorResponse es la mitad que casi nadie usa y que casi todo el mundo necesita. Sin ella, cada componente recibe la estructura de error cruda del transporte y termina inspeccionando códigos HTTP en la capa de presentación, que es exactamente donde no deben vivir. Normalizar el fallo a un tipo propio —un código de dominio y un mensaje ya decidido— hace que la UI trate errores sin saber que existe HTTP.
La frontera es tentadora y por eso se abusa de ella. Ordenar por fecha, filtrar los borradores, calcular totales, agrupar por autor: todo eso cabe técnicamente en un transformResponse y todo eso es un error. Lo que guardes ahí se convierte en la verdad de la cache, y una cache que ya viene ordenada y filtrada según lo que hoy pide una pantalla no sirve para la pantalla de mañana que quiere otro orden. La regla es nítida: transformResponse traduce forma, nunca decide contenido. Renombrar campos, aplanar envoltorios, convertir tipos primitivos y normalizar identificadores es traducción. Ordenar, filtrar y agregar es punto de vista, y el punto de vista pertenece a los selectores.
Normalizar dentro de la cache
Que RTK Query cachee no significa que la forma cacheada sea buena. Una consulta que devuelve un array de cien posts guarda un array, y toda búsqueda por identificador dentro de ese array es lineal, toda actualización puntual exige reconstruirlo entero y toda referencia cruzada desde otra entrada duplica los datos. createEntityAdapter, que ya conoces del Nivel 5, encaja aquí sin fricción porque su salida —un diccionario de entidades más un array de identificadores— es un valor serializable perfectamente válido como contenido de cache.
import { createEntityAdapter } from "@reduxjs/toolkit";
const adaptador = createEntityAdapter<Post>({ sortComparer: (a, b) => b.creadoEn - a.creadoEn });
const estadoInicial = adaptador.getInitialState();
listarPosts: builder.query<ReturnType<typeof adaptador.getInitialState>, void>({
query: () => "posts",
transformResponse: (crudo: { data: PostRemoto[] }) =>
adaptador.setAll(estadoInicial, crudo.data.map(aPost)),
providesTags: (r) => [
...(r?.ids ?? []).map((id) => ({ type: "Post" as const, id: String(id) })),
{ type: "Post" as const, id: "LIST" },
],
}),
Nótese que las etiquetas ahora se derivan del array de identificadores en lugar de recorrer entidades, lo cual es más barato y más directo. Y nótese también la sutileza del sortComparer: el adaptador mantiene el orden en el array de identificadores, de modo que el orden es un dato explícito de la estructura y no una propiedad accidental de cómo llegó la respuesta. Esto contradice solo en apariencia la advertencia anterior sobre no ordenar en transformResponse; lo que el adaptador fija es un orden canónico de almacenamiento, no el orden de una vista concreta, y cualquier vista sigue libre de reordenar al leer.
transformResponse
Se ejecuta una vez, al escribir. Define qué es verdad en la cache. Traduce forma, jamás decide punto de vista.
Selector sobre el resultado
Se ejecuta al leer, memoizado. Define cómo se mira la verdad. Ordena, filtra y agrega sin guardar nada.
Selectores sobre entradas de cache
Hay dos vías para leer una entrada de cache con transformación, y confundirlas cuesta re-renderizados. La primera es selectFromResult, una opción del propio hook que aplica una función al estado de la consulta y hace que el componente solo se re-renderice cuando el resultado de esa función cambia por comparación superficial. Es la vía correcta cuando una vista quiere una porción diminuta de una entrada grande.
function TituloDePost({ id }: { id: string }) {
const { titulo } = useListarPostsQuery(undefined, {
selectFromResult: ({ data }) => ({ titulo: data?.entities[id]?.titulo }),
});
return <h3>{titulo ?? "sin titulo"}</h3>;
}
La segunda vía es api.endpoints.listarPosts.select, que devuelve un selector del store estándar y por tanto componible con createSelector de Reselect. Esta es la vía cuando la derivación es cara y se comparte entre varios componentes, o cuando hay que cruzar una entrada de cache con estado de cliente que vive en otro slice.
import { createSelector } from "@reduxjs/toolkit";
const seleccionarLista = api.endpoints.listarPosts.select();
export const seleccionarPostsVisibles = createSelector(
[seleccionarLista, (estado: RootState) => estado.filtros.autorId],
(resultado, autorId) => {
const estado = resultado.data;
if (!estado) return [];
return estado.ids
.map((id) => estado.entities[id]!)
.filter((p) => autorId === null || p.autorId === autorId);
},
);
Este selector expresa con claridad la división de trabajo del nivel: la cache guarda todos los posts normalizados, el slice de cliente guarda qué autor está filtrado, y el cruce de ambos no se guarda en ninguna parte porque se computa. Nada en el store contiene la lista visible, y esa ausencia es una decisión de diseño, no un olvido.
flowchart LR A[respuesta cruda del backend] --> B[transformResponse] B --> C[entrada de cache normalizada] C --> D[selectFromResult para una porcion] C --> E[createSelector con estado de cliente] E --> F[vista derivada y efimera] D --> F style B fill:#fab387,color:#11111b style C fill:#89b4fa,color:#11111b style F fill:#a6e3a1,color:#11111b
Dónde no debe vivir un dato derivado
Queda una tercera opción que el diagrama omite deliberadamente porque es la equivocada: guardar el dato derivado. Escribir en un slice el recuento de posts visibles, o el mapa de posts por autor, o la lista ya filtrada, es tentador porque parece ahorrar cómputo. Lo que en realidad hace es crear una segunda copia de una verdad que ya existía, y con ella la obligación permanente de mantener ambas sincronizadas ante cualquier invalidación, cualquier actualización optimista y cualquier refresco en segundo plano. El coste de recomputar un filtro sobre unos cientos de elementos es despreciable; el coste de un contador que miente después de un refetch es un bug que nadie reproduce.
Antes de guardar cualquier estructura derivada, formula el dato como pregunta y observa si ya tiene respuesta. Cuántos posts hay del autor siete es una pregunta cuya respuesta está contenida en la lista de posts; guardarla es guardar dos veces lo mismo. Si de la lista no puede deducirse —porque el servidor la calcula sobre datos que tú no tienes, como un total global con paginación— entonces no es derivada, es un dato propio y su sitio es un campo de la respuesta o un endpoint aparte. La frontera entre derivado y primitivo no la decide la comodidad: la decide si la información está o no está contenida en lo que ya tienes.
Hay una asimetría que este nivel entero gira en torno a ella y que aquí alcanza su forma más pura. Computar un dato derivado tiene un coste puntual, medible, acotado y que ocurre en el momento en que alguien lo necesita. Guardarlo tiene un coste distribuido, invisible y perpetuo: desde el instante en que existe una segunda copia, cada camino del código que altere la primera adquiere en silencio la obligación de actualizar la segunda, y esa obligación no aparece en ninguna firma, ningún tipo la vigila y ningún test la exige. La has contraído sin escribirla. Por eso los bugs de datos derivados obsoletos no se descubren donde se causaron: aparecen semanas después, en el camino que alguien añadió sin saber que existía una promesa que cumplir, y se manifiestan como una interfaz que muestra dos números que no cuadran entre sí. Lo revelador es que RTK Query multiplica el número de esos caminos justamente porque hace bien su trabajo. Una cache que se refresca por invalidación, por foco recuperado, por reconexión, por polling y por parche optimista tiene muchas más entradas de escritura que un slice manejado a mano, y cada una de ellas es una oportunidad de romper una copia que no sabía de su existencia. La conclusión no es que derivar sea una optimización elegante sino la única disciplina que escala: el estado mínimo no es un ideal de pureza funcional, es una estrategia de reducción de superficie de fallo. Guarda lo que nadie puede deducir y solo eso; todo lo demás compútalo en la frontera de lectura, donde es imposible que quede rancio porque no dura lo suficiente para envejecer. La memoria es barata y el cómputo también; lo caro, siempre, ha sido mantener sincronizadas dos verdades que debieron ser una.
- Localiza en tus componentes todo el código que renombra campos, parsea fechas o desenvuelve respuestas, y muévelo a un único
transformResponsepor endpoint. - Añade
transformErrorResponsey comprueba que ningún componente vuelve a inspeccionar un código HTTP para decidir qué mostrar. - Convierte una consulta de colección a estado normalizado con
createEntityAdaptery reescribe susprovidesTagsa partir del array de identificadores. - Sustituye una lectura de entrada completa por
selectFromResultacotado a una porción y mide con el profiler cuántos re-renderizados desaparecen. - Construye con
createSelectoruna vista que cruce una entrada de cache con un filtro guardado en un slice de cliente, y verifica que esa vista no se guarda en ningún sitio. - Busca en tu store algún dato derivado persistido, aplícale la prueba de la pregunta y elimínalo si la respuesta ya estaba contenida en otro dato.