---
title: "Data en Swift: lo que TanStack Query hacía por ti y cómo construir tu capa de caché"
description: "En Swift no hay TanStack Query. Cómo diseñar la capa de datos de una app iOS con URLSession y Codable, un repositorio que emite con AsyncStream, SwiftData o GRDB como caché local, invalidación y supabase-swift."
author: Ramón Chancay
date: 2026-09-06
lang: es
tags: [Swift, URLSession, AsyncStream, SwiftData, GRDB, TanStack Query, Supabase]
canonical: https://www.ramonchancay.me/es/blog/data-en-swift-lo-que-tanstack-query-hacia-por-ti
---

# Data en Swift: lo que TanStack Query hacía por ti y cómo construir tu capa de caché

TanStack Query hacía tanto por una app de React Native que muchos equipos no sabían cuánto hasta que lo perdieron: deduplicaba peticiones, cacheaba por clave, marcaba datos como obsoletos, refrescaba al volver a primer plano, reintentaba con backoff, permitía actualizaciones optimistas y exponía todo eso como un hook. En Swift no existe un equivalente con esa adopción. Hay que decirlo sin rodeos: vas a construir tu capa de caché. La buena noticia es que las piezas para hacerlo bien están en el sistema y en dos o tres librerías maduras, y el diseño resultante suele quedar más claro que un `useQuery` con veinte opciones. Este post es ese diseño: `URLSession` con `Codable`, un repositorio que emite un flujo con `AsyncStream`, una base de datos local como caché y las reglas de invalidación que reemplazan a `staleTime`.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> Resumen para perezosos <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li><code>URLSession</code> con <code>async/await</code> y <code>Codable</code> cubre el transporte; no necesitas Alamofire para una API REST normal. Lo que falta es todo lo que TanStack Query ponía encima: caché, obsolescencia, deduplicación y refresco.</li>
<li>El patrón que funciona es "la base de datos local es la caché": el repositorio escribe en SwiftData o GRDB lo que trae de la red, y la vista observa la base de datos, no la red. Así el offline, el refresco y la consistencia entre pantallas salen del mismo diseño; la deduplicación de peticiones es un actor de diez líneas.</li>
<li>La invalidación se modela explícitamente: una marca de tiempo por colección y una política por caso de uso. Es menos automático que <code>staleTime</code> y más fácil de razonar cuando algo se muestra viejo.</li>
</ul>
</div>
</details>

**En este artículo:**

- **La capa base** — [Lo que TanStack Query hacía](#lo-que-tanstack-query-hacía-y-que-ahora-es-tu-trabajo) · [URLSession y Codable](#urlsession-y-codable-el-transporte-sin-librerías)
- **El diseño** — [El repositorio con AsyncStream](#el-repositorio-que-emite-un-flujo-asyncstream) · [La base de datos como caché](#la-base-de-datos-local-como-caché-swiftdata-o-grdb)
- **La operación** — [Invalidación y refresco](#invalidación-refresco-y-actualizaciones-optimistas) · [supabase-swift](#supabase-swift-cuando-el-backend-ya-es-supabase)

## Lo que TanStack Query hacía, y que ahora es tu trabajo

Conviene enumerar lo que un `useQuery({ queryKey: ["orders"], queryFn })` resolvía sin que lo pidieras, porque cada punto es una decisión que ahora te toca tomar:

| Lo que hacía TanStack Query | Lo que hay en Swift |
|---|---|
| Caché en memoria por clave | Nada por defecto; `URLCache` solo cachea respuestas HTTP con cabeceras válidas |
| Deduplicación de peticiones simultáneas | Nada; dos vistas que piden lo mismo hacen dos peticiones |
| `staleTime` y refresco en segundo plano | Nada; hay que guardar cuándo se trajo cada dato |
| Refresco al volver a primer plano | `scenePhase` te avisa; el refresco lo disparas tú |
| Reintentos con backoff exponencial | Nada; una función de reintento propia |
| Actualizaciones optimistas y rollback | Nada; el repositorio las implementa |
| Estado `isLoading` / `isFetching` / `error` | Un enum `Loadable` propio, como en el [post de Swift](/es/blog/swift-para-quien-domina-typescript) |
| Persistencia de la caché entre arranques | SwiftData, GRDB o Core Data |
| Paginación infinita | Un método `loadMore()` en el repositorio con un cursor |

Hay librerías que intentan cubrir la lista completa, pero ninguna tiene la adopción ni el mantenimiento que justifiquen ponerla en el centro de la app. Lo que la comunidad de Swift hace en la práctica es distinto: en vez de una caché en memoria por clave, usa la base de datos local como única fuente de verdad para la UI, y trata la red como un proceso que actualiza esa base de datos. Con ese cambio de enfoque, la mitad de la tabla desaparece.

## URLSession y Codable: el transporte sin librerías

Para una API REST con JSON, `URLSession` con `async/await` es suficiente y no vale la pena agregar Alamofire. Un cliente mínimo que uso en casi todos los proyectos:

```swift
struct APIClient: Sendable {
    let baseURL: URL
    let session: URLSession
    let decoder: JSONDecoder
    let tokenProvider: @Sendable () async -> String?

    func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
        try await send(request(path, method: "GET", query: query))
    }

    func post<T: Decodable, Body: Encodable>(_ path: String, body: Body) async throws -> T {
        var req = request(path, method: "POST")
        req.httpBody = try JSONEncoder().encode(body)
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        return try await send(req)
    }

    private func request(_ path: String, method: String, query: [URLQueryItem] = []) -> URLRequest {
        var url = baseURL.appending(path: path)
        if !query.isEmpty { url.append(queryItems: query) }
        var req = URLRequest(url: url)
        req.httpMethod = method
        return req
    }

    private func send<T: Decodable>(_ request: URLRequest) async throws -> T {
        var request = request
        if let token = await tokenProvider() {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
        guard (200..<300).contains(http.statusCode) else {
            throw APIError.http(status: http.statusCode, body: data)
        }
        return try decoder.decode(T.self, from: data)
    }
}

enum APIError: Error {
    case invalidResponse
    case http(status: Int, body: Data)
}
```

Lo que aporta frente a un `fetch` con Axios: tipos en la salida (`let orders: [OrderDTO] = try await client.get("/orders")`), errores como valores que un `catch` puede distinguir por caso, y una sola función `send` donde viven la autenticación y el manejo de estado HTTP. Los reintentos se agregan como una función genérica que envuelve `send`:

```swift
func withRetry<T>(
    attempts: Int = 3,
    delay: Duration = .milliseconds(400),
    _ operation: () async throws -> T
) async throws -> T {
    var lastError: Error?
    for attempt in 0..<attempts {
        do {
            return try await operation()
        } catch let error as APIError {
            // Los 4xx no se reintentan: el servidor ya dijo que no.
            if case .http(let status, _) = error, (400..<500).contains(status) { throw error }
            lastError = error
        } catch {
            lastError = error
        }
        // Después del último intento no hay nada que esperar.
        guard attempt < attempts - 1 else { break }
        // Backoff exponencial (400, 800, 1600 ms...) con algo de jitter para que
        // muchos clientes no reintenten en el mismo instante.
        let backoff = delay * (1 << attempt) + .milliseconds(Int.random(in: 0...100))
        try await Task.sleep(for: backoff)
    }
    throw lastError ?? APIError.invalidResponse
}
```

Sobre `URLCache`: existe, respeta `Cache-Control` y `ETag`, y para recursos estáticos (imágenes, catálogos que cambian poco) resuelve la caché HTTP sin escribir nada. Para datos de negocio no sirve como caché de aplicación: trabaja a nivel de petición y respuesta HTTP (puedes leer una respuesta guardada, incluso sin red, con la política de caché adecuada), pero no te da el modelo de datos, la invalidación con semántica de dominio ni la observación que la UI necesita. Es una caché de transporte, no de datos.

## El repositorio que emite un flujo (AsyncStream)

En TanStack Query, un componente se suscribe a una consulta y recibe actualizaciones cuando la caché cambia, sin importar quién la cambió. El equivalente en Swift es un repositorio que expone un flujo asíncrono en vez de una función que devuelve una vez:

```swift
protocol OrderRepository: Sendable {
    /// Emite la lista actual y cada cambio posterior, hasta que se cancele.
    /// Puede fallar: el almacén es una base de datos, y una base de datos falla.
    func observeAll() -> any AsyncSequence<[Order], any Error>
    /// Trae de la red y actualiza el almacén local. Los observadores reciben el cambio.
    func refresh() async throws
    func create(_ draft: OrderDraft) async throws -> Order
}
```

La vista (o su ViewModel) consume el flujo con `for await` dentro de `.task`, que además cancela la suscripción al salir de pantalla:

```swift
@Observable
@MainActor
final class OrdersViewModel {
    private(set) var orders: [Order] = []
    private(set) var isRefreshing = false
    private(set) var error: Error?

    private let repository: OrderRepository

    init(repository: OrderRepository) { self.repository = repository }

    func observe() async {
        do {
            for try await orders in repository.observeAll() {
                self.orders = orders
            }
        } catch {
            // Un fallo del almacén es un estado que la UI muestra, no un fin silencioso.
            self.error = error
        }
    }

    func refresh() async {
        isRefreshing = true
        defer { isRefreshing = false }
        do { try await repository.refresh() } catch { self.error = error }
    }
}
```

```swift
struct OrdersScreen: View {
    @State private var model: OrdersViewModel

    var body: some View {
        List(model.orders) { OrderRow(order: $0) }
            .refreshable { await model.refresh() }
            .task { await model.observe() }      // se cancela al salir de pantalla
            .task { await model.refresh() }      // primera carga
    }
}
```

Con este diseño, `observeAll` no toca la red. Emite lo que hay en el almacén local, de inmediato, y vuelve a emitir cada vez que ese almacén cambia. `refresh` es el único que habla con la API, y no devuelve datos: escribe en el almacén, y los observadores se enteran. El protocolo pide solo un `AsyncSequence` que puede fallar; el tipo concreto lo pone la implementación: un `AsyncStream` si el almacén está en memoria, o la secuencia que la base de datos ya ofrece, como se ve más abajo con GRDB. Ese desacople es el que da, sin código adicional:

- **Una fuente para muchas vistas:** tres pantallas observando `orders` leen el mismo almacén y ven el mismo dato en el mismo instante.
- **Offline:** sin red, `observeAll` sigue emitiendo lo último que se guardó.
- **Consistencia:** crear un pedido escribe en el almacén, y la lista se actualiza sin que nadie la refresque.

Lo que este diseño **no** da solo es la deduplicación de peticiones: tres pantallas que llaman a `refresh()` al aparecer siguen siendo tres peticiones. Esa parte hay que escribirla, y son diez líneas que muestro junto al repositorio.

```text
Vista A ──observa──┐
Vista B ──observa──┼──► Almacén local (SwiftData / GRDB) ◄──escribe── refresh()
Vista C ──observa──┘            ▲                                        ▲
                                │                                        │
                          create(), update()                      APIClient (red)
```

## La base de datos local como caché: SwiftData o GRDB

El almacén del diagrama es una base de datos. Las dos opciones que uso, y el criterio, se detallan en el [post de persistencia](/es/blog/persistencia-en-ios-swiftdata-grdb-keychain); aquí va lo que importa para la capa de datos.

**SwiftData** viene con el sistema, se declara con macros sobre clases (`@Model`) y se observa desde SwiftUI con `@Query`. Para el patrón repositorio, un `ModelContext` en segundo plano escribe lo que llega de la red, y la vista usa `@Query` directamente o el repositorio expone el flujo. Su punto fuerte es la integración con SwiftUI; su punto débil es que las consultas complejas y el control fino de transacciones se quedan cortos.

**GRDB** es SQLite con una API de Swift muy cuidada, migraciones explícitas, tipos `Codable` como filas y, lo que importa para este post, `ValueObservation`: una consulta que emite cada vez que su resultado cambia, y que desde la propia librería se consume como `AsyncSequence` con `values(in:)`, con cancelación y errores incluidos. No hace falta envolverla:

```swift
final class GRDBOrderRepository: OrderRepository {
    private let db: DatabaseQueue
    private let client: APIClient

    private let inFlight = InFlight()

    func observeAll() -> any AsyncSequence<[Order], any Error> {
        // GRDB ya entrega la observación como AsyncSequence, con cancelación
        // y con los errores de la base de datos propagados al consumidor.
        ValueObservation
            .tracking { db in try Order.order(Column("createdAt").desc).fetchAll(db) }
            .values(in: db)
    }

    func refresh() async throws {
        try await inFlight.run("orders") { [client, db] in
            let dtos: [OrderDTO] = try await withRetry { try await client.get("/orders") }
            let orders = dtos.map { $0.toDomain() }
            try await db.write { db in
                try Order.deleteAll(db)
                for order in orders { try order.insert(db) }
                try SyncMark(collection: "orders", fetchedAt: .now).save(db)
            }
        }
    }
}

/// Deduplica trabajo en curso por clave: si ya hay un refresh de una colección
/// corriendo, la segunda llamada espera ese resultado en vez de lanzar otra petición.
actor InFlight {
    private var tasks: [String: Task<Void, any Error>] = [:]

    func run(_ key: String, _ operation: @escaping @Sendable () async throws -> Void) async throws {
        if let running = tasks[key] { return try await running.value }
        let task = Task { try await operation() }
        tasks[key] = task
        defer { tasks[key] = nil }
        try await task.value
    }
}
```

La escritura reemplaza la colección completa dentro de una transacción; los observadores reciben una sola emisión con el resultado final. El actor `InFlight` es la deduplicación de peticiones que el diseño no regala: la primera pantalla lanza la petición y las siguientes esperan la misma `Task`. Para colecciones grandes, un `upsert` por id y un borrado de los que ya no vienen es más eficiente, pero la estructura es la misma.

Mi criterio corto: **SwiftData si la app es pequeña, solo iOS 17 o superior, y las vistas pueden usar `@Query` directamente; GRDB si hay consultas con joins, migraciones que van a cambiar, sincronización con conflictos, o si quieres que la capa de datos no dependa de SwiftUI**. En apps con un backend serio, casi siempre GRDB.

## Invalidación, refresco y actualizaciones optimistas

`staleTime` en TanStack Query decía "estos datos valen cinco minutos; después, refresca en segundo plano al usarlos". Sin la librería, esa política se escribe a mano, pero el diseño de arriba la hace corta: cada colección guarda cuándo se trajo (`SyncMark`), y una política decide si hace falta refrescar.

```swift
enum FreshnessPolicy {
    case always                   // cada vez que la pantalla aparece
    case maxAge(Duration)         // solo si el último refresh es más viejo que esto
    case manual                   // solo con pull-to-refresh o acción explícita
}

extension GRDBOrderRepository {
    func refreshIfNeeded(policy: FreshnessPolicy) async throws {
        switch policy {
        case .always:
            try await refresh()
        case .maxAge(let maxAge):
            let last = try await db.read { try SyncMark.fetchOne($0, key: "orders")?.fetchedAt }
            if last.map({ Date.now.timeIntervalSince($0) > maxAge.seconds }) ?? true {
                try await refresh()
            }
        case .manual:
            break
        }
    }
}
```

La vista llama a `refreshIfNeeded(policy: .maxAge(.minutes(5)))` en su `.task`, y al volver a primer plano con `.onChange(of: scenePhase)`. Es más explícito que `staleTime`, y cuando alguien pregunta "¿por qué esta pantalla muestra datos viejos?" la respuesta está en una línea de la pantalla, no en una configuración global.

Las **actualizaciones optimistas** siguen el mismo principio: escribir en el almacén local antes de la red, y revertir si la red falla.

```swift
func create(_ draft: OrderDraft) async throws -> Order {
    // 1. Escribe una versión provisional: la UI la muestra de inmediato.
    let provisional = Order(draft: draft, id: .temporary(), status: .pending)
    try await db.write { try provisional.insert($0) }

    do {
        // 2. Red. Si funciona, reemplaza la provisional por la real.
        let created: OrderDTO = try await client.post("/orders", body: draft)
        let order = created.toDomain()
        try await db.write { db in
            try Order.deleteOne(db, key: provisional.id)
            try order.insert(db)
        }
        return order
    } catch {
        // 3. Rollback: la provisional desaparece y la lista se actualiza sola.
        try await db.write { try Order.deleteOne($0, key: provisional.id) }
        throw error
    }
}
```

La invalidación entre colecciones (crear un pedido invalida el resumen del perfil) se resuelve con el mismo mecanismo: el repositorio de pedidos actualiza también la fila que el resumen observa, o borra su `SyncMark` para que el próximo `refreshIfNeeded` la traiga. No hay `queryClient.invalidateQueries`; hay una escritura en la base de datos que los observadores ven.

> Lo que más me sorprendió al construir esto por primera vez fue cuánto código de TanStack Query no hacía falta replicar. El refresco al montar, el offline y la sincronización entre pantallas no son funcionalidades que haya que implementar: son consecuencias de que la vista observe la base de datos en lugar de la red. La deduplicación de peticiones sí hay que escribirla, pero son diez líneas en un actor.

## supabase-swift: cuando el backend ya es Supabase

Si el backend es Supabase, la librería oficial `supabase-swift` cubre auth, base de datos (PostgREST), storage, realtime y funciones, con `async/await` y `Codable`. Se integra en el diseño anterior sin cambiarlo: reemplaza a `APIClient` dentro del repositorio, y el almacén local sigue siendo la fuente para la UI.

```swift
let supabase = SupabaseClient(supabaseURL: url, supabaseKey: anonKey)

func refresh() async throws {
    let rows: [OrderRow] = try await supabase
        .from("orders")
        .select()
        .order("created_at", ascending: false)
        .execute()
        .value
    try await db.write { /* upsert de rows */ }
}
```

Dos decisiones que conviene tomar temprano:

- **Realtime como fuente de escritura en el almacén, no como estado de la vista.** El canal de realtime entrega inserciones y cambios; el repositorio los escribe en GRDB o SwiftData, y las vistas se enteran por el mismo flujo de siempre. Si la vista se suscribe al canal directamente, pierdes el offline y duplicas lógica.
- **La sesión de auth vive en Keychain.** `supabase-swift` lo hace por defecto con su almacén de sesión; si lo reemplazas, no lo lleves a `UserDefaults`. El [post de persistencia](/es/blog/persistencia-en-ios-swiftdata-grdb-keychain) explica por qué.

Lo que sigue siendo tuyo con Supabase: la política de frescura, el upsert local, las actualizaciones optimistas y los reintentos. La librería resuelve el transporte y la autenticación, no la caché.

## Preguntas frecuentes

### ¿No existe ninguna librería tipo TanStack Query para Swift?

Existen varias con esa intención, y algunas son razonables para proyectos pequeños. El problema es la adopción: ninguna tiene una comunidad comparable, y poner el centro de la app sobre una dependencia que puede quedar sin mantener es un riesgo que prefiero no correr. El patrón "base de datos local como caché" no depende de nadie y cubre el caso de uso completo.

### ¿Es obligatorio tener base de datos? Mi app solo lista cosas de una API.

No. Para una app sin offline y con pocas pantallas, un almacén en memoria dentro de un actor (un diccionario por colección con su marca de tiempo) y un `AsyncStream` como secuencia funciona, y se puede cambiar por GRDB después sin tocar las vistas. La abstracción que importa es el repositorio que emite un flujo, no la base de datos.

### ¿Cómo manejo paginación infinita?

El repositorio guarda el cursor de la última página y expone `loadMore()`, que trae la siguiente y la agrega al almacén. La vista observa la lista completa y llama a `loadMore()` cuando la última fila aparece (`.onAppear` en la fila, o `.task(id:)`). Un `refresh()` reinicia el cursor y reemplaza la colección.

### ¿Cómo evito que dos pantallas disparen dos `refresh()` a la vez?

Con un actor que guarde la `Task` en curso por colección, como el `InFlight` del repositorio de GRDB: si ya hay un refresh corriendo, la segunda llamada espera el resultado de la primera en vez de lanzar otra petición. La base de datos como fuente unifica lo que las vistas leen; las peticiones las deduplica ese actor.

### ¿Y las imágenes? ¿Hay algo como `expo-image` con caché?

`AsyncImage` viene con SwiftUI, cachea con las reglas HTTP estándar y, desde iOS 27, deja personalizar el `URLRequest` y la `URLSession` que usa (`asyncImageURLSession(_:)`), con lo que puedes darle tu propia configuración de caché. Lo que sigue sin tener es lo que una app con muchas imágenes termina necesitando: caché en memoria y disco con límites propios, prefetch, procesamiento y placeholders progresivos. Para eso, Nuke o Kingfisher siguen siendo las librerías maduras, y son de las pocas dependencias de terceros que considero obligatorias en ese tipo de app.

## Conclusión

En Swift no hay TanStack Query, y la forma correcta de reemplazarlo no es reescribirlo, es cambiar el diseño: `URLSession` con `Codable` para el transporte, un repositorio que emite un `AsyncStream` desde un almacén local, y la red como un proceso que escribe en ese almacén. Con eso, offline, consistencia entre pantallas y refresco al volver a primer plano dejan de ser funcionalidades y pasan a ser consecuencias. Lo que sí hay que escribir es la deduplicación de peticiones, la política de frescura, las actualizaciones optimistas con rollback y los reintentos, y las cuatro son cortas cuando el resto del diseño está bien.

Para empezar: escribe el `APIClient` de veinte líneas, define un protocolo de repositorio con `observeAll()` y `refresh()`, respáldalo con GRDB o SwiftData, y agrega la política de frescura solo a las pantallas que la necesiten. El [siguiente post](/es/blog/concurrencia-en-swift-adios-al-event-loop) explica el modelo que este dio por sentado con cada `async`, `Sendable` y `@MainActor`: la concurrencia de Swift 6, donde un data race es un error de compilación.
