Data en Swift: lo que TanStack Query hacía por ti y cómo construir tu capa de caché
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.
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.
Resumen para perezosos
URLSessionconasync/awaityCodablecubre 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.- 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.
- 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
staleTimey más fácil de razonar cuando algo se muestra viejo.
En este artículo:
- La capa base — Lo que TanStack Query hacía · URLSession y Codable
- El diseño — El repositorio con AsyncStream · La base de datos como caché
- La operación — Invalidación y refresco · supabase-swift
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 |
| 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:
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:
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:
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:
@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 }
}
}
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
ordersleen el mismo almacén y ven el mismo dato en el mismo instante. - Offline: sin red,
observeAllsigue 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.
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; 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:
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.
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.
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.
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-swiftlo hace por defecto con su almacén de sesión; si lo reemplazas, no lo lleves aUserDefaults. El post de persistencia 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 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.