SwiftTCA@SharedSwiftUIiOS

Pokédex en TCA (parte 4): favoritos con @Shared

En la parte 3 navegamos al detalle. Ahora agregamos algo que en la Pokédex MVVM nos costó bastante: favoritos que se marcan en el detalle y se ven al instante en la lista. En la serie anterior lo resolvimos con SwiftData, inyectando el ModelContext en cada view model que lo tocaba y disparando refresco a mano. Funciona, pero es fricción constante.

En TCA la mecánica cambia: @Shared declara “esta propiedad la comparten varias features”, y las mantiene sincronizadas sin bindings ni notificaciones. La persistencia es una opción de la propia declaración.

El plan

Un favorito es un Set<Int> (los IDs de los Pokémon marcados). Lo queremos:

  • Persistido en disco (que sobreviva al cierre de la app).
  • Compartido entre la lista y el detalle.
  • Mutable desde el detalle (marcar/desmarcar).
  • Legible desde la lista (para pintar la estrella).

Un solo tipo, dos features consumiéndolo. No hay razón para propagar ModelContext ni para notificar cambios: @Shared cubre las cuatro cosas.

Declarando la clave compartida

Repetir .fileStorage(url) cada vez que se usa es error prone: un typo en la ruta y esa feature deja de compartir sin ningún error de compilación. TCA permite extender SharedReaderKey para tener claves con nombre y tipo:

import ComposableArchitecture
import Foundation

extension SharedKey where Self == FileStorageKey<Set<Int>>.Default {
    static var favorites: Self {
        Self[.fileStorage(.documentsDirectory.appending(path: "favorites.json")),
             default: []]
    }
}

Ese .fileStorage(url) persiste el Set<Int> como JSON en el documento favorites.json del contenedor de la app. Requiere Codable, que Set<Int> cumple sin nada extra. El valor por defecto (un set vacío) queda en un solo lugar. Y Set<Int> es lo bastante pequeño para no preocuparse por escrituras: el sistema se encarga.

Consumiendo la clave

En cada feature que necesite favoritos, una línea en el estado:

@Reducer
struct PokemonDetailFeature {
    @ObservableState
    struct State: Equatable {
        let pokemon: Pokemon
        var extra: LoadState<PokemonDetail> = .idle
        @Shared(.favorites) var favorites
    }
    // ...
}

Y en la lista:

@Reducer
struct PokemonListFeature {
    @ObservableState
    struct State: Equatable {
        var loadState: LoadState<[Pokemon]> = .idle
        var isLoadingMore = false
        var canLoadMore = true
        @Presents var destination: Destination.State?
        @SharedReader(.favorites) var favorites
    }
    // ...
}

Fíjate en la asimetría. El detalle usa @Shared (lectura y escritura, porque desde ahí el usuario marca la estrella). La lista usa @SharedReader (solo lectura, porque solo la muestra). Esa asimetría documenta la intención mejor que un comentario y la hace cumplir el compilador: en la lista .withLock ni siquiera existe.

Ambas features siguen viendo el mismo Set<Int>. Cuando el detalle lo modifica, la lista se entera y su vista se actualiza. Nada más que declararlo.

Mutando con withLock

Aquí viene el detalle que sorprende: no se muta directamente. Se hace bajo un candado.

case .favoriteTapped:
    let id = state.pokemon.id
    state.$favorites.withLock { favorites in
        if favorites.contains(id) {
            favorites.remove(id)
        } else {
            favorites.insert(id)
        }
    }
    return .none

Parece ceremonia, y no lo es. Un valor compartido puede leerse y escribirse desde varias features y desde efectos concurrentes al mismo tiempo. Sin withLock, un favorites.insert() que por dentro es leer-modificar-escribir puede intercalarse con otro y perder una escritura. Este es exactamente el bug que en un singleton con un var aparece uno de cada mil ejecuciones y nunca se reproduce.

withLock bloquea el “si contiene, quita; si no, agrega” entero como una sola operación. No hay ventana entre la lectura y la escritura.

La estrella en el detalle

struct PokemonDetailView: View {
    @Bindable var store: StoreOf<PokemonDetailFeature>

    private var isFavorite: Bool {
        store.favorites.contains(store.pokemon.id)
    }

    var body: some View {
        List {
            Section {
                AsyncImage(url: store.pokemon.spriteURL) { $0.resizable().scaledToFit() }
                    placeholder: { ProgressView() }
                    .frame(height: 160)
            }

            // ... la sección "Información" de la parte 3 ...
        }
        .navigationTitle(store.pokemon.name.capitalized)
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    store.send(.favoriteTapped)
                } label: {
                    Image(systemName: isFavorite ? "star.fill" : "star")
                        .foregroundStyle(isFavorite ? .yellow : .primary)
                }
                .accessibilityLabel(isFavorite ? "Quitar de favoritos" : "Marcar como favorito")
            }
            ToolbarItem(placement: .cancellationAction) {
                Button("Cerrar") { store.send(.closeTapped) }
            }
        }
        .onAppear { store.send(.onAppear) }
    }
}

La vista lee store.favorites directamente y calcula isFavorite sobre la marcha. Cuando el usuario toca la estrella, mandamos .favoriteTapped, el reducer actualiza el conjunto compartido, la vista se re-renderiza. Sin bindings a mano.

La estrella en la lista

En la fila lo mismo, en modo lectura:

struct PokemonRow: View {
    let pokemon: Pokemon
    let isFavorite: Bool

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: pokemon.spriteURL) { $0.resizable().scaledToFit() }
                placeholder: { ProgressView() }
                .frame(width: 48, height: 48)

            VStack(alignment: .leading) {
                Text(pokemon.name.capitalized).font(.headline)
                Text(pokemon.types.joined(separator: ", "))
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            if isFavorite {
                Image(systemName: "star.fill").foregroundStyle(.yellow)
            }
        }
    }
}

Y en la vista de la lista pasamos el flag calculado desde el estado:

ForEach(pokemons) { pokemon in
    PokemonRow(
        pokemon: pokemon,
        isFavorite: store.favorites.contains(pokemon.id)
    )
    .contentShape(Rectangle())
    .onTapGesture { store.send(.rowTapped(pokemon)) }
    // onAppear para paginación como en la parte 2
}

Prueba el flujo completo en el simulador: entras a un Pokémon, marcas la estrella, cierras el detalle, y ves la estrella amarilla en la fila. Sin haber tocado un solo binding a mano.

Qué hace realmente .fileStorage

Vale la pena entender qué está pasando debajo, para saber qué esperar:

  • La primera vez que se lee, si el archivo no existe, se usa el valor por defecto (set vacío).
  • Cuando el Set cambia, TCA serializa a JSON y escribe en disco de forma asíncrona. No bloquea el hilo principal.
  • Todas las instancias @Shared(.favorites) reciben el nuevo valor a través del sistema de observación de Swift.
  • En los tests, la estrategia de persistencia se sustituye por un almacén simulado en memoria. Los tests no tocan el disco real y arrancan limpios.

Ese último punto es importante y evita el clásico “un test pasa solo, pero falla en la suite completa” cuando compartes estado entre tests.

Por qué esto es tan distinto de SwiftData

La Pokédex MVVM usaba SwiftData para lo mismo, y funcionaba. Pero cada view model que tocaba favoritos tenía que:

  • Recibir el ModelContext (por inyección o vía @Environment).
  • Crear un @Query o consultarlo manualmente.
  • Notificar refresco a otras vistas que también dependieran.

En TCA todo eso desaparece. @Shared es una anotación en el estado. La persistencia es un detalle de la declaración. La sincronización es automática. Y no perdimos nada real: si mañana quisieras algo más pesado (una base con relaciones, migraciones, queries complejas), SwiftData sigue disponible y se conecta al reducer igual que cualquier otra dependencia. Para “un conjunto de IDs favoritos”, @Shared es sencillamente el ajuste correcto.

Lo que llevamos

  • @Shared(.fileStorage) para persistir un Set<Int> de favoritos.
  • @SharedReader en las features que solo leen.
  • withLock para mutar sin race conditions.
  • Clave con nombre para evitar typos y centralizar el valor por defecto.
  • Cero bindings a mano entre features.

Qué sigue

En la parte 5 llegan la búsqueda y los filtros. Vamos a rescatar el patrón de debounce que estrenamos en la parte 1 de TCA y a ponerlo sobre una app de verdad: buscador por nombre con cancelación en vuelo, y filtros por tipo modelados como una sub-feature con BindingReducer. Ahí veremos cómo un reducer padre reacciona a acciones de un hijo para coordinar sin acoplarse.

Fuentes: Sharing state (docs) · repo de la app