SwiftTCA@SharedSwiftUIiOS

Pokédex in TCA (part 4): favorites with @Shared

In part 3 we navigated to the detail. Now we add something that in the MVVM Pokédex took real effort: favorites you mark in the detail and see instantly in the list. In the previous series we solved it with SwiftData, injecting the ModelContext into every view model that touched it and triggering refreshes by hand. It works, but it’s constant friction.

In TCA the mechanic changes: @Shared declares “this property is shared across several features” and keeps them in sync without bindings or notifications. Persistence is an option of the declaration itself.

The plan

A favorite is a Set<Int> (the IDs of the marked Pokémon). We want it:

  • Persisted to disk (surviving app kills).
  • Shared between the list and the detail.
  • Mutable from the detail (mark/unmark).
  • Readable from the list (to render the star).

One type, two features consuming it. No reason to propagate ModelContext or to notify changes: @Shared covers all four.

Declaring the shared key

Repeating .fileStorage(url) every time you use it is error prone: a typo in the path and that feature silently stops sharing, with no compile error. TCA lets you extend SharedReaderKey to get named, typed keys:

import ComposableArchitecture
import Foundation

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

That .fileStorage(url) persists the Set<Int> as JSON in the favorites.json document of the app container. It requires Codable, which Set<Int> satisfies without anything extra. The default value (an empty set) lives in one place. And Set<Int> is small enough not to worry about writes: the system handles it.

Consuming the key

In every feature that needs favorites, one line in the state:

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

And in the list:

@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
    }
    // ...
}

Notice the asymmetry. The detail uses @Shared (read and write, because the user marks the star from there). The list uses @SharedReader (read only, because it just displays it). That asymmetry documents intent better than a comment and the compiler enforces it: in the list .withLock doesn’t even exist.

Both features still see the same Set<Int>. When the detail modifies it, the list finds out and its view updates. Nothing more than declaring it.

Mutating with withLock

Here’s the detail that surprises everyone: you don’t mutate directly. You do it under a lock.

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

It looks like ceremony, and it isn’t. A shared value can be read and written from several features and from concurrent effects at the same time. Without withLock, a favorites.insert() that internally is read-modify-write can interleave with another and lose a write. This is exactly the bug that in a singleton with a plain var shows up once every thousand runs and can never be reproduced.

withLock locks the whole “if it contains, remove; otherwise add” as one operation. There’s no window between the read and the write.

The star in the detail

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)
            }

            // ... the "Info" section from part 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 ? "Remove from favorites" : "Mark as favorite")
            }
            ToolbarItem(placement: .cancellationAction) {
                Button("Close") { store.send(.closeTapped) }
            }
        }
        .onAppear { store.send(.onAppear) }
    }
}

The view reads store.favorites directly and computes isFavorite on the fly. When the user taps the star, we send .favoriteTapped, the reducer updates the shared set, the view re-renders. No hand-rolled bindings.

The star in the list

Same thing in the row, read-only:

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)
            }
        }
    }
}

And in the list view we pass the flag computed from state:

ForEach(pokemons) { pokemon in
    PokemonRow(
        pokemon: pokemon,
        isFavorite: store.favorites.contains(pokemon.id)
    )
    .contentShape(Rectangle())
    .onTapGesture { store.send(.rowTapped(pokemon)) }
    // onAppear for pagination as in part 2
}

Try the full flow in the simulator: open a Pokémon, tap the star, close the detail, and see the yellow star on the row. Without having touched a single hand-rolled binding.

What .fileStorage actually does

Worth understanding what’s happening underneath, to know what to expect:

  • First time it’s read, if the file doesn’t exist, the default value is used (empty set).
  • When the Set changes, TCA serializes to JSON and writes to disk asynchronously. It doesn’t block the main thread.
  • Every @Shared(.favorites) instance receives the new value through Swift’s Observation system.
  • In tests, the persistence strategy is swapped for an in-memory mock store. Tests don’t touch real disk and start clean.

That last point is important and prevents the classic “a test passes alone but fails in the full suite” when you share state across tests.

Why this is so different from SwiftData

The MVVM Pokédex used SwiftData for the same job, and it worked. But every view model that touched favorites had to:

  • Receive the ModelContext (by injection or through @Environment).
  • Create a @Query or query it manually.
  • Notify refreshes to other views that also depended.

In TCA all of that vanishes. @Shared is an annotation on the state. Persistence is a detail of the declaration. Syncing is automatic. And we didn’t lose anything real: if tomorrow you wanted something heavier (a store with relationships, migrations, complex queries), SwiftData is still available and plugs into the reducer like any other dependency. For “a set of favorite IDs”, @Shared is simply the right fit.

What we’ve got

  • @Shared(.fileStorage) to persist a Set<Int> of favorites.
  • @SharedReader in features that only read.
  • withLock to mutate without race conditions.
  • A named key to avoid typos and centralize the default value.
  • Zero hand-rolled bindings between features.

What’s next

In part 5 search and filters arrive. We’ll bring back the debounce pattern we introduced in TCA part 1 and put it on a real app: name search with in-flight cancellation, and type filters modeled as a sub-feature with BindingReducer. There we’ll see how a parent reducer reacts to a child’s actions to coordinate without coupling.

Sources: Sharing state (docs) · app repo