Building Narrative AI Apps in SwiftUI - Lessons from Six Months in Production

SwiftUI and AI narrative apps

Patterns, pitfalls, and the SwiftUI architecture I wish I had known before shipping an AI-driven narrative app to the App Store.

22nd Apr 2026

swiftswiftuiaiiosindiearchitecture

Six months in

Elsewhere has been live on the App Store for about six months as I write this. It's a small narrative app — an AI life simulator where every choice splits the universe — and the build-to-ship cycle taught me more about SwiftUI than the previous three years combined.

This is the post I wish someone had handed me before I started. Not a tutorial. A field guide.

The fundamental tension

Narrative AI apps have a structural problem that traditional apps don't:

  • The screen waits on a model.
  • The model is slow (300ms on-device, 2–4 seconds in the cloud).
  • The model can fail.
  • The user shouldn't feel any of this.

Most SwiftUI architectures I see online assume your data either lives locally (instant) or comes from a REST API (predictable). LLM responses are neither. They're streaming, lossy, variable-length, and emotionally charged — the user is waiting to see who they became in the other universe. A spinner is not enough.

The pattern that finally worked

After three rewrites, here's what stuck. I call it the Anticipation–Stream–Commit loop.

@Observable
final class EventState {
    enum Phase {
        case idle
        case anticipating(String)
        case streaming(partial: String)
        case ready(Event)
        case failed(Error)
    }

    var phase: Phase = .idle
}

Each phase maps to a distinct SwiftUI view treatment:

  • Anticipating: the universe is "thinking" — a soft pulse animation, a quote from a previous moment, no spinner
  • Streaming: tokens land on screen one at a time, slower than the model can produce them (this is intentional — see below)
  • Ready: the event card is solid, tappable, real
  • Failed: gentle, never an alert — "the universe got quiet, try again"

The view becomes a state machine in body:

var body: some View {
    switch state.phase {
    case .idle:              EmptyView()
    case .anticipating(let q): AnticipationView(quote: q)
    case .streaming(let p):  StreamingView(text: p)
    case .ready(let event):  EventCard(event: event)
    case .failed:            QuietRecoveryView()
    }
}

What I love about this: SwiftUI's diffing handles the transitions. No withAnimation calls scattered through the codebase. No "did the spinner show?" race conditions.

Slow down the stream

This is counterintuitive: I deliberately throttle the model's output before showing it to the user. The on-device model produces a 60-word event in about 400ms. That's too fast. The user doesn't have time to feel the moment.

func reveal(_ text: String) async {
    for token in tokens(in: text) {
        state.phase = .streaming(partial: currentText + token)
        try? await Task.sleep(for: .milliseconds(30))
    }
}

30ms per token feels like writing. 5ms per token feels like a printer. The model is fast — the reveal is the design choice. Apps that show text instantly miss the whole point of narrative.

Caching across universes

In Elsewhere, every choice spawns a new universe. The naïve approach is to ask the model "given this biography, generate an event" every single time. That's expensive (cloud), slow (on-device with cold cache), and produces uneven results.

What I do instead: a tiered cache keyed by biography hash and recent-choice fingerprint.

struct EventCache {
    let store: Cache<EventKey, Event> = .init(limit: 200)

    func event(for context: EventContext) async throws -> Event {
        let key = EventKey(from: context)
        if let hit = store[key] { return hit }

        let event = try await ai.generate(context)
        store[key] = event
        return event
    }
}

About 18% of generation calls hit cache. That's 18% of latency the user doesn't feel, and 18% of API spend I don't pay. The win compounds as a user's universe map grows — recurring patterns become local.

What @Observable changed for me

@Observable (the macro-based observation system) is the single biggest SwiftUI improvement of the past two years. I no longer write @Published on a thousand properties. I no longer fight ObservableObject re-render storms. The view only re-renders when the property it reads actually changes.

The pattern:

@Observable
final class UniverseStore {
    var current: Universe?
    var map: UniverseMap = .empty
    var credits: Int = 5
}

In a view that reads only credits:

@Environment(UniverseStore.self) private var store

var body: some View {
    Text("\(store.credits) splits left")
}

When current changes but credits doesn't, this view doesn't re-render. With the old @ObservableObject, it would have. Six months in, I cannot overstate how much smoother my app feels for this one change.

State persistence: Isar to SwiftData

I started Elsewhere on Isar — Flutter heritage, fast, familiar. I migrated to SwiftData around month three. Honestly: SwiftData was painful in 2024 and is fine in 2026. Migrations still scare me but the day-to-day API is good, the integration with @Observable is seamless, and the model definitions are pure Swift.

@Model
final class Universe {
    var id: UUID
    var biography: String
    var choices: [Choice]
    var createdAt: Date

    init(biography: String) {
        self.id = UUID()
        self.biography = biography
        self.choices = []
        self.createdAt = .now
    }
}

Fetch from a view:

@Query(sort: \Universe.createdAt, order: .reverse)
private var universes: [Universe]

It feels like SwiftUI's @State for persistent data. Which is what we wanted all along.

The bug I'd warn you about

One subtle thing: SwiftUI re-evaluates body when state changes, but the body itself shouldn't kick off async work. The first version of Elsewhere had this:

var body: some View {
    EventCard(event: event)
        .onAppear {
            Task { await state.generateNext() }  // ❌
        }
}

That onAppear fires every time the view re-enters the hierarchy. Which in a map-of-universes app, is constantly. I burned hundreds of dollars in unnecessary Gemini calls before catching it.

Use .task(id:) instead:

.task(id: universe.id) {
    await state.generateNext()
}

This runs once per universe.id, cancels cleanly when the view goes away, and never double-fires. Use it for anything async tied to view appearance.

What I'd tell past-me

  1. Pick on-device first, cloud as fallback. Cost compounds invisibly.
  2. Treat AI latency as a design problem, not an engineering problem.
  3. Don't let the LLM dictate your view architecture — wrap it in state machines.
  4. Trust @Observable. Trust .task(id:). Stop using onAppear for anything async.
  5. Cache aggressively. Users won't notice; your wallet will.

Six months in, Elsewhere is profitable, the codebase is small (under 12k lines of Swift), and I sleep at night. The SwiftUI + AI stack is no longer experimental. It's just iOS development now.

— Met