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

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
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
- Pick on-device first, cloud as fallback. Cost compounds invisibly.
- Treat AI latency as a design problem, not an engineering problem.
- Don't let the LLM dictate your view architecture — wrap it in state machines.
- Trust
@Observable. Trust.task(id:). Stop usingonAppearfor anything async. - 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