Clean Frontend Architecture: cómo usar React Query sin romper tus casos de uso

Imagen generada por IA de marcos cuadrados concéntricos que representan las capas de React Query en arquitectura limpia, blog de Orbitant

React Query resuelve problemas reales: caché, refetching en segundo plano, invalidación, reintentos. Pero cuando intentas aplicar React Query arquitectura limpia, te encuentras con la misma trampa una y otra vez: la librería acaba convirtiéndose en la arquitectura. No solo dándole soporte.

En un artículo anterior, hablamos de cómo organizar el código frontend por dominios de negocio aplicando arquitectura limpia: Dominio, Aplicación, Infraestructura y Presentación.

La idea central era simple: las reglas de negocio no deben depender de React, de las APIs, de bases de datos ni de librerías de UI. Tus casos de uso describen qué hace la aplicación; las capas externas deciden cómo se ejecuta, se cachea o se muestra.

Eso plantea una pregunta real: ¿dónde encaja React Query? React Query no debería reemplazar tus casos de uso. Debería adaptarlos a las necesidades de renderizado asíncrono de React.

El error: usar React Query en todas partes

La forma más habitual de empezar es llamar a useQuery directamente dentro de los componentes:

const { data, isLoading } = useQuery({
  queryKey: ['furniture', roomId],
  queryFn: () => fetch(`/api/rooms/${roomId}/furniture`).then(r => r.json()),
})

Funciona… al principio, pero este pequeño ejemplo ya mezcla varias responsabilidades:

  • El componente sabe cómo se obtienen los datos.
  • La query key se inventa dentro del componente.
  • La API de React Query se filtra directamente a la UI.
  • La intención de negocio queda oculta tras detalles técnicos.

El componente ya no está diciendo: «Necesito el mobiliario de esta habitación», sino: «sé cuál es la clave de caché, el endpoint, la estrategia de fetching y la librería de estado asíncrono que usamos.» Eso es demasiada responsabilidad para un componente.

React Query pertenece a la capa de Presentación

En una arquitectura frontend limpia, el caso de uso pertenece a la capa de Aplicación. Un caso de uso debería tener este aspecto:

export type UseCase<Params, Data> = {
  id: string
  execute: (params: Params) => Promise<Data>
}

Tiene una identidad y una función de ejecución. No sabe nada de React ni tampoco de React Query. No sabe si el resultado se va a mostrar en una página, se va a cachear, se va a precargar o se va a invalidar.

React Query no pertenece a esa capa. De hecho, su sitio está en los hooks adaptadores de presentación: hooks cuya responsabilidad es adaptar los casos de uso a las necesidades de renderizado de React.

Componente
  -> Hook de feature
      -> Adaptador genérico
          -> React Query
              -> Caso de uso
                  -> Repositorio

El caso de uso permanece limpio. React Query se queda al margen.

El adaptador genérico

En lugar de usar useQuery directamente en todas partes, podemos crear un adaptador genérico:

export const useCustomQuery = ({ useCase, params, options }) => {
  const query = useQuery({
    queryKey: queryKeyFn({ useCase, params }),
    queryFn: () => useCase.execute(params),
    enabled: !options?.disabled,
  })

  return {
    data: query.data,
    error: query.error,
    isLoading: query.isPending,
    isFetching: query.isFetching,
  }
}

Este hook no es solo un wrapper, diríamos que es una frontera. La aplicación decide qué conceptos quiere exponer: data, error, isLoading, isFetching. Esos nombres forman parte del lenguaje de nuestro frontend; no están ahí porque React Query los llame así hoy.

Si React Query cambia isPending, isLoading o cualquier otro estado interno en una versión futura, los componentes no tienen por qué notarlo. El adaptador cambia, pero el resto de la aplicación sigue usando el mismo contrato. Ahí está el beneficio arquitectónico.

No expongas toda la API de React Query

Un error habitual es crear un wrapper, pero seguir pasando todas las opciones de React Query a través de él. Eso no resuelve el problema de acoplamiento, solo lo oculta detrás de otro fichero. El adaptador solo debe exponer las opciones que tu arquitectura quiere soportar:

type QueryOptions<Params, Data> = {
  disabled?: boolean
  onSuccess?: (data: Data, params: Params) => void
  onError?: (error: DomainError, params: Params) => void
}

Estos nombres también forman parte de tu lenguaje. Quizás React Query lo llama enabled o puede que otra librería lo llame skip. Por tanto, tu aplicación no necesita seguir el vocabulario de la librería porque el adaptador mapea tu vocabulario al de la librería: la query se adapta a tu arquitectura, no al revés.

Los componentes deberían usar hooks específicos de feature

El adaptador genérico es útil, pero los componentes normalmente no deberían llamarlo directamente. En lugar de esto:

useCustomQuery({
  useCase: getFurnitureUseCase,
  params: { roomId },
})

Es preferible esto otro:

const { data, isLoading, isFetching } = useGetFurnitureQuery({ roomId })

El hook de feature debería exponer el caso de uso al componente, pero no debería importar repositorios directamente. Los repositorios son Infraestructura, y si el hook de presentación los importa directamente, la Presentación acaba dependiendo de la Infraestructura, rompiendo silenciosamente la regla de dependencias que queremos proteger.

La versión más laxa que se suele ver es:

// Presentación importando Infraestructura directamente
import { furnitureRepositories } from '@/infrastructure/furniture'

const getFurnitureUseCase =
  livingRoomUseCases.getFurniture(furnitureRepositories)

export const useGetFurnitureQuery = ({ roomId }) => {
  return useCustomQuery({ useCase: getFurnitureUseCase, params: { roomId } })
}

Esto no es incorrecto necesariamente: es una lectura más laxa de la arquitectura, perfectamente aceptable en muchos proyectos, siempre que sea una elección consciente.

Lo que ganas: menos ceremonia. Sin proveedor y sin contenedor de DI, el caso de uso es un singleton a nivel de módulo, todo colocado junto y fácil de leer. Para una aplicación pequeña o con una sola implementación, la composition root completa puede no merecer la pena.

Lo que pierdes son dos cosas.

  • Primero, testabilidad: el caso de uso queda vinculado a los furnitureRepositories reales en tiempo de importación, así que para testear el hook hay que hacer un mock del módulo de infraestructura (jest.mock('@/infrastructure/furniture')) en lugar de simplemente pasar un repositorio falso.
  • Segundo, intercambiabilidad: el caso de uso a nivel de módulo es un singleton vinculado a un solo repositorio, así que no se puede cambiar de implementación fácilmente: mock vs. real, por entorno, multi-tenant, offline vs. API.

Una regla práctica: la versión de importación directa está bien cuando tienes una sola implementación del repositorio, no necesitas intercambiarla, y estás cómodo con el mock a nivel de módulo en los tests. En el momento en que alguna de esas condiciones deja de cumplirse, la flexibilidad empieza a tener un coste, y ahí es donde la composition root merece la pena.

La solución más estricta es una composition root: un punto único de cableado donde los casos de uso se construyen con sus repositorios. El hook de presentación recibe entonces un caso de uso ya compuesto en lugar de construir uno.

La composition root no pertenece a ninguna de las cuatro capas: vive en el punto de arranque de la aplicación —el código de bootstrap que ensambla la app, por ejemplo src/composition/, junto a donde montas React— porque es el único lugar que puede ver todas las capas a la vez. Por eso puede conectar Infraestructura con Aplicación sin que ninguna de las dos dependa de la otra.

// src/composition/livingRoom.ts
// Vive fuera de las capas, en el nivel de cableado de la app —
// el único lugar donde Infraestructura se encuentra con Aplicación.
export const livingRoomComposition = {
  getFurniture: livingRoomUseCases.getFurniture(furnitureRepositories),
  // ...otros casos de uso ya cableados
}

El hook de feature simplemente lee el caso de uso que necesita:

export const useGetFurnitureQuery = ({ roomId }) => {
  return useCustomQuery({
    useCase: livingRoomComposition.getFurniture,
    params: { roomId },
  })
}

Así la Presentación nunca importa un repositorio directamente. El repositorio se inyecta una sola vez, en la composition root, de modo que el hook solo toca el contrato del caso de uso. Cómo se construye y se proporciona esa composition root a la aplicación es un tema propio, que cubriré en detalle en un artículo separado.

El componente entonces solo pide los datos que necesita:

const { data, isLoading } = useGetFurnitureQuery({ roomId })

No sabe nada de repositorios, query keys ni opciones de React Query. Ese es el nivel de abstracción correcto para un componente.

Las query keys deberían venir de los casos de uso

React Query necesita query keys, pero en una arquitectura limpia esas claves no deberían ser strings arbitrarios creados por los componentes. La query key debería describir la llamada al caso de uso, no el componente que necesita los datos.

queryKeyFn({ useCase, params })

Por ejemplo:

queryKey: [useCase.id, JSON.stringify(params)]

Esto significa que los datos cacheados se identifican por:

  • El caso de uso que se ejecuta
  • Los parámetros con los que se ejecuta

Así, en lugar de pensar:

['furniture-page', roomId]

Pensamos:

[getFurnitureUseCase.id, { roomId }]

Es una diferencia sutil, pero importante: la caché sigue el comportamiento de la aplicación y ya no está vinculada a una página o componente.

La invalidación también debería usar el lenguaje de los casos de uso

La misma idea aplica a las mutaciones: después de crear un mueble, normalmente hay que refrescar la lista de mobiliario de la habitación. La mutación no debería invalidar una string arbitraria:

queryClient.invalidateQueries(['furniture'])

Debería invalidar el resultado cacheado del caso de uso de lectura afectado:

queryClient.invalidateQueries({
  queryKey: queryKeyFn({
    useCase: getFurnitureUseCase,
    params: { roomId },
  }),
})

El flujo queda claro:

createFurniture tiene éxito
  -> getFurniture está desactualizado para esta habitación
  -> invalidar getFurnitureUseCase + params

React Query sigue haciendo la invalidación de caché, pero el código describe la relación de la aplicación. Ese es el punto.

Las mutaciones siguen la misma regla

El mismo patrón funciona para los comandos:

export const useCreateFurnitureMutation = () => {
  return useCustomMutation({
    useCase: createFurnitureUseCase,
    options: {
      onSuccess: (_, params) => {
        invalidateGetFurnitureQuery({ roomId: params.roomId })
      },
    },
  })
}

El componente recibe una API de comando estable:

const { execute, isLoading } = useCreateFurnitureMutation()

De nuevo, isLoading es el contrato de nuestra aplicación. Internamente podría mapear a isPending, isMutating o algo diferente en el futuro. El componente no necesita saberlo.

Por qué esto importa en las migraciones

Este patrón es especialmente valioso cuando las librerías evolucionan. En un proyecto, React Query se usaba directamente en muchos componentes. Cuando TanStack Query cambió la semántica de los estados entre versiones mayores, la migración fue dolorosa porque cada componente dependía del contrato de la librería.

Pero el problema no era React Query, sino que la aplicación había filtrado React Query por todas partes. Si cada componente desestructura el estado específico del proveedor directamente, cada componente pasa a formar parte de la superficie de migración.

Con los hooks adaptadores, la superficie de migración es más pequeña y explícita:

React Query cambia
  -> actualizar adaptadores genéricos
  -> actualizar hooks de feature solo donde el comportamiento realmente cambió
  -> los componentes siguen usando el contrato de la aplicación

El objetivo no es que reemplazar React Query sea trivial. React Query hace muchas cosas: caché, hidratación, invalidación, reintentos, actualizaciones optimistas. En definitiva, el objetivo es mantener la dependencia contenida.

React Query arquitectura limpia: los límites correctos

A veces se piensa que la arquitectura limpia significa evitar librerías útiles, pero no es exactamente así. La arquitectura limpia no consiste en escribir todo desde cero, sino en decidir dónde se permite que vivan las dependencias.

React Query es una gran herramienta para aplicaciones frontend, pero no debería definir tus casos de uso, tu lenguaje de negocio ni los contratos de tus componentes. Úsalo donde le corresponde:

  • Los casos de uso definen el comportamiento de la aplicación.
  • Los repositorios gestionan el acceso a los datos.
  • Los hooks de feature exponen los casos de uso a los componentes.
  • Los adaptadores genéricos traducen el contrato de tu aplicación a React Query.
  • Los componentes consumen hooks específicos de feature.

Así obtienes los beneficios de React Query sin darle control sobre tu arquitectura.

Lo que ganas cuando React Query se queda en el borde

React Query no debería reemplazar tus casos de uso: debería adaptarlos. Cuando colocas React Query detrás de hooks adaptadores de presentación, mantienes intacta la regla de la arquitectura limpia: las dependencias apuntan hacia adentro, y el comportamiento de negocio permanece independiente de los detalles del framework.

Tus componentes se simplifican. Tus query keys adquieren significado. Tu lógica de invalidación sigue los casos de uso. Tus estados de carga forman parte de tu propio lenguaje de frontend. Y cuando la librería cambia, no cambia contigo toda la aplicación.

Sigue nuestro viaje construyendo agentes IA desde dentro