Disponible para freelance¡Contáctame para que pueda ayudar a que tu negocio crezca o convertir tu idea en realidad!

Estoy interesado
Usando TanStack Query para Reemplazar la Gestión de Estado Compleja

Usando TanStack Query para Reemplazar la Gestión de Estado Compleja

He visto el mismo slice de Redux en tres codebases distintos: isLoading, error, data, y un action creator para cada transición posible entre esos estados. El fetch arranca, se dispara FETCH_USERS_REQUEST. El fetch tiene éxito, se dispara FETCH_USERS_SUCCESS. El fetch falla, se dispara FETCH_USERS_FAILURE. Multiplica eso por cada endpoint de tu aplicación y terminas con una capa de gestión de estado que existe solo para llevar respuestas del servidor hasta el store.

Eso no es gestión de estado. Es boilerplate disfrazado de arquitectura.

El problema real: dos tipos de estado, una sola herramienta

La mayoría de las aplicaciones mezclan dos categorías de estado completamente distintas y las tratan igual:

  • Estado de cliente — toggles de UI, campos de formulario, visibilidad de modales, preferencia de tema. Vive solo en el navegador y solo tú controlas cuándo cambia.
  • Estado de servidor — datos que viven en un servidor, que no controlas, y que pueden quedar desactualizados en el momento exacto en que otro usuario u otra pestaña los toca.

Redux (y Zustand, y Context + useReducer) están hechos para estado de cliente. Son síncronos, predecibles, y te dan control total sobre las transiciones. El estado de servidor rompe esas tres premisas:

  • Es asíncrono por naturaleza.
  • Puede estar desactualizado incluso antes de que tu componente renderice.
  • Varios componentes suelen querer los mismos datos, y no quieres cinco fetches separados para eso.

Cuando fuerzas el estado de servidor dentro de una herramienta pensada para estado de cliente, terminas reimplementando cache, deduplicación, reintentos y refetch en segundo plano a mano — y mal, porque ese nunca fue el problema que esa herramienta debía resolver.


Qué hace realmente TanStack Query

TanStack Query (antes React Query) no es un gestor de estado en el sentido de Redux. Es una capa de sincronización de datos. Le das una clave y una función que trae los datos, y él se encarga de:

  • Cache por clave, así requests idénticos entre componentes comparten un solo fetch
  • Deduplicación de requests en curso
  • Refetch en segundo plano cuando la ventana recupera el foco o vuelve la conexión
  • Reintentos con backoff exponencial
  • Estados de loading y error, derivados automáticamente — nunca los disparas tú mismo

El cambio de mentalidad es lo importante: dejas de preguntarte "¿cómo actualizo el store cuando termine este request?" y empiezas a preguntarte "¿qué datos necesita este componente, y qué tan frescos tienen que estar?"


Configurando el proyecto

Instala la librería y envuelve tu aplicación con un provider, una sola vez:

npm install @tanstack/react-query
// app/providers.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export function AppProviders({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // el dato se considera "fresco" durante 1 minuto
            retry: 1,
          },
        },
      })
  )

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

Esa es toda la configuración necesaria. Sin slices, sin middleware, sin combineReducers.


Reemplazando un flujo de fetch de Redux

Aquí una configuración típica de Redux para cargar los proyectos de un usuario — el tipo de código que he borrado más veces de las que puedo contar:

// ❌ Redux: tres action types, un reducer y un thunk solo para traer una lista
const projectsSlice = createSlice({
  name: 'projects',
  initialState: { data: [], loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchProjects.pending, (state) => {
        state.loading = true
        state.error = null
      })
      .addCase(fetchProjects.fulfilled, (state, action) => {
        state.loading = false
        state.data = action.payload
      })
      .addCase(fetchProjects.rejected, (state, action) => {
        state.loading = false
        state.error = action.error.message
      })
  },
})

export const fetchProjects = createAsyncThunk('projects/fetch', async (userId: string) => {
  const res = await fetch(`/api/users/${userId}/projects`)
  if (!res.ok) throw new Error('Failed to fetch projects')
  return res.json()
})

Y el componente todavía tiene que disparar la action, seleccionar el estado y lidiar con el timing:

// ❌ El componente sigue gestionando el ciclo de vida del fetch a mano
function ProjectsList({ userId }: { userId: string }) {
  const dispatch = useDispatch()
  const { data, loading, error } = useSelector((state: RootState) => state.projects)

  useEffect(() => {
    dispatch(fetchProjects(userId))
  }, [dispatch, userId])

  if (loading) return <Spinner />
  if (error) return <ErrorMessage message={error} />
  return (
    <ul>
      {data.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

Ahora lo mismo con TanStack Query:

// ✅ TanStack Query: fetch, cache y estados en un solo hook
import { useQuery } from '@tanstack/react-query'

function ProjectsList({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['projects', userId],
    queryFn: async () => {
      const res = await fetch(`/api/users/${userId}/projects`)
      if (!res.ok) throw new Error('Failed to fetch projects')
      return res.json()
    },
  })

  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage message={error.message} />
  return (
    <ul>
      {data.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

Sin slice. Sin thunk. Sin dispatch. Si otro componente en la misma página también llama a useQuery(['projects', userId]), reutiliza el mismo cache en lugar de disparar un segundo request.

Las mutations funcionan de la misma manera

Actualizar datos sigue el mismo patrón, y obtienes invalidación de cache gratis:

import { useMutation, useQueryClient } from '@tanstack/react-query'

function useCreateProject(userId: string) {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (name: string) => {
      const res = await fetch(`/api/users/${userId}/projects`, {
        method: 'POST',
        body: JSON.stringify({ name }),
      })
      return res.json()
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['projects', userId] })
    },
  })
}

invalidateQueries le dice a TanStack Query: "este dato está desactualizado, tráelo de nuevo la próxima vez que se use." Esa única línea reemplaza toda una clase de bugs del tipo "me olvidé de actualizar el store después de esta mutation".


Errores comunes

  • Meter datos de servidor en Redux "por si acaso". Si el valor viene de una API, no pertenece a tu store global de cliente. Deja que el cache del query se encargue.
  • Usar la combinación useEffect + useState en lugar de useQuery. Ese es exactamente el patrón que TanStack Query existe para reemplazar — pierdes cache, reintentos y deduplicación sin necesidad.
  • Olvidar que la queryKey debe ser única. ['projects'] y ['projects', userId] son caches distintos. Si tu clave no incluye cada variable de la que depende el query, vas a terminar sirviendo el dato de un usuario a otro.
  • Tratar isLoading como "todavía no hay dato". Usa isPending e isFetching correctamente — isFetching también es true durante refetches en segundo plano, y mostrar un spinner de página completa en esos casos crea una experiencia molesta.

Buenas prácticas

  • Mantén el estado de cliente y el estado de servidor en herramientas separadas. Zustand o Context para estado de UI, TanStack Query para todo lo que venga de un servidor.
  • Define el staleTime de forma deliberada. El valor por defecto de 0 hace refetch de manera agresiva. Para datos que no cambian seguido (perfil del usuario, configuraciones), unos minutos de desactualización suelen estar bien y reducen bastante el tráfico de red.
  • Coloca las query keys cerca de las queries que las usan. Un archivo queries/projects.ts que exporte un hook useProjectsQuery(userId) es mucho más fácil de mantener que useQuery disperso por los componentes.
  • Usa select para moldear el dato, en vez de otro useEffect. Si necesitas un valor derivado, transfórmalo dentro de la propia query:
useQuery({
  queryKey: ['projects', userId],
  queryFn: fetchProjects,
  select: (data) => data.filter((p) => !p.archived),
})
  • Haz prefetch al pasar el mouse o en la transición de ruta para páginas que sabes que el usuario está a punto de visitar. queryClient.prefetchQuery hace que la navegación se sienta instantánea sin gestión de estado extra.

Qué hacer ahora

Si hoy mantienes un store de Redux, no lo reescribas todo de una vez. Elige un slice que sea puramente dato de servidor — el que tenga más boilerplate de pending/fulfilled/rejected — y reemplázalo con una sola llamada a useQuery. Borra el reducer, el thunk y los selectors. Mide cuánto código desapareció.

Haz esto con cada slice de "traer y guardar" en tu aplicación, y es muy probable que tu store de Redux se reduzca hasta quedarse solo con lo que siempre debió guardar: estado real de cliente. Esa es una buena señal de que tu arquitectura por fin coincide con la forma de tus datos.