SwiftTCAArchitectureSwiftUIiOS

TCA from scratch (part 3): shared state and persistence

Every app reaches the moment when two screens need the same piece of data. Favorites you mark in the detail and see in the list. The authenticated user that eight features read. The settings that change behavior everywhere.

The easy escapes are well known and both are bad. Duplicating the data in each feature means they drift apart: you favorite something in the detail, go back to the list, and there’s still no star. Passing it as a parameter through five levels works, but every intermediate feature carries something it doesn’t care about, and adding a new value means touching five files.

The third escape, the singleton, solves ergonomics and breaks everything else: mutable global state that no feature declares, that no test can isolate, and that nobody can trace back to whoever changed it.

TCA has an answer: @Shared.

The minimum

You declare the shared property in the state of every feature that needs it:

@Reducer
struct FavoritesFeature {
    @ObservableState
    struct State: Equatable {
        @Shared(.appStorage("favorites")) var favorites: Set<Int> = []
    }
}

That’s it. Any other feature that declares @Shared(.appStorage("favorites")) with the same type sees exactly the same value. When one changes it, the other finds out and its view updates.

There are three strategies:

  • .inMemory("key") shares for the session and is lost on relaunch. For things like the state of an in-progress download.
  • .appStorage("key") persists to UserDefaults, simple types only: Bool, Int, String, Data.
  • .fileStorage(url) persists to disk as JSON. Requires Codable, and it’s what you use for collections and models.

The important part here is that persistence is a detail of the declaration, not of the code using it. Switching from .inMemory to .fileStorage doesn’t require touching a single reducer.

Sharing without persisting

You can also share a value that lives only in memory and is injected by the parent. There the property is declared with no strategy:

@ObservableState
struct State: Equatable {
    @Shared var user: User
}

And the parent builds it by passing its own shared reference:

state.destination = .detail(
    DetailFeature.State(user: state.$user, repo: repo)
)

Notice the $: you don’t pass the value, you pass the shared reference. The child doesn’t get a copy that will go stale, it gets a window onto the same data.

This is the difference from a singleton: the dependency stays explicit. Open a feature’s State and you see exactly what it shares. There’s no invisible access from anywhere in the codebase.

Mutating: withLock and why it exists

Here comes the detail that caught me off guard. You don’t mutate a @Shared directly:

// Doesn't compile
state.favorites.insert(repo.id)

// This does
state.$favorites.withLock { $0.insert(repo.id) }

It looks like pointless ceremony, but it isn’t. A shared value can be read and written from several features and from concurrent effects. Without withLock, a favorites.insert() that is internally read-modify-write can interleave with another and lose a write.

withLock locks the whole operation, not just the final write. It’s exactly the bug that, in a singleton with a plain var, shows up once every thousand runs and can never be reproduced.

A full example:

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

The whole “if it contains, remove; otherwise add” happens under a single lock. There’s no window between the read and the write.

Reusable keys

Repeating .appStorage("favorites") across six files is asking for a typo. Misspell the key in one place and that feature silently stops sharing, with no compile error at all.

The library lets you extend SharedReaderKey to get named, typed keys:

extension SharedKey where Self == AppStorageKey<Set<Int>>.Default {
    static var favorites: Self {
        Self[.appStorage("favorites"), default: []]
    }
}

And in each feature:

@Shared(.favorites) var favorites

The default value lives in one place, the compiler guarantees the type, and the typo stops being possible. On a project with more than two or three shared values, this stops being optional.

Read-only

Not every feature that reads a shared value should be able to change it. A stats screen reads favorites, it doesn’t edit them.

That’s what @SharedReader is for:

@ObservableState
struct State: Equatable {
    @SharedReader(.favorites) var favorites
}

Same data, same syncing, but no withLock available. It’s the kind of restriction that documents intent better than a comment, and that the compiler actually enforces.

In tests

The obvious risk of shared state is one test contaminating the next. TCA covers it: persistence strategies use mock storage in tests. Nothing touches real UserDefaults or disk, and every test starts clean.

You can set the initial value in the test state declaration, and verify changes like any other:

@Test
func markFavorite() async {
    let store = await TestStore(
        initialState: DetailFeature.State(repo: repo)
    ) {
        DetailFeature()
    }

    await store.send(.favoriteTapped(repo)) {
        $0.$favorites.withLock { $0 = [1] }
    }
}

When the change happens inside an effect rather than directly in the reducer, you verify it with store.assert { ... } after the effect finishes.

What it doesn’t solve

@Shared is good, but it isn’t magic:

  • It isn’t Hashable. Because of reference semantics, a state containing a @Shared can’t be a dictionary key or go into a Set.
  • Codable isn’t free. If you need to serialize a state with shared values, you have to write encode/decode by hand.
  • It’s still global state. With better properties than a singleton, but overusing it re-couples features that should be independent. The question before sharing anything should always be the same: are these really two views of the same data, or two pieces of data that happen to match today?

That last point is the hardest in practice, and I say it from experience: @Shared is so convenient that it invites you to share things that don’t need it. Let it slide and global state grows just like in any other architecture, only with nicer syntax.

What’s next

That gives us the four base tools: features, effects, composition and shared state. Enough to build a complete real app.

In part 4, which closes the series, we go deep on testing (exhaustive vs non-exhaustive, controlled clocks, testing long-running effects) and make the honest comparison against MVVM and the other architectures: when TCA is worth it, when it’s overkill, and what hurts in production.

And after the series, the practical part: rebuilding the Pokédex from the previous series entirely in TCA, to compare both approaches on the same problem.

Sources: Sharing state (docs)