[{"data":1,"prerenderedAt":1181},["ShallowReactive",2],{"blog-post-/blogs/swiftui-ai-narrative-apps-april-2026":3},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"image":11,"alt":12,"ogImage":11,"tags":13,"published":20,"body":21,"_type":1174,"_id":1175,"_source":1176,"_file":1177,"_stem":1178,"_extension":1179,"sitemap":1180},"/blogs/swiftui-ai-narrative-apps-april-2026","blogs",false,"","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","/blogs-img/blog5.jpg","SwiftUI and AI narrative apps",[14,15,16,17,18,19],"swift","swiftui","ai","ios","indie","architecture",true,{"type":22,"children":23,"toc":1163},"root",[24,33,39,44,50,55,80,93,99,112,232,237,280,293,370,383,389,401,454,466,472,477,482,581,586,592,619,624,677,689,733,761,767,779,897,902,925,938,944,962,1015,1035,1048,1078,1091,1097,1147,1152,1157],{"type":25,"tag":26,"props":27,"children":29},"element","h3",{"id":28},"six-months-in",[30],{"type":31,"value":32},"text","Six months in",{"type":25,"tag":34,"props":35,"children":36},"p",{},[37],{"type":31,"value":38},"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.",{"type":25,"tag":34,"props":40,"children":41},{},[42],{"type":31,"value":43},"This is the post I wish someone had handed me before I started. Not a tutorial. A field guide.",{"type":25,"tag":26,"props":45,"children":47},{"id":46},"the-fundamental-tension",[48],{"type":31,"value":49},"The fundamental tension",{"type":25,"tag":34,"props":51,"children":52},{},[53],{"type":31,"value":54},"Narrative AI apps have a structural problem that traditional apps don't:",{"type":25,"tag":56,"props":57,"children":58},"ul",{},[59,65,70,75],{"type":25,"tag":60,"props":61,"children":62},"li",{},[63],{"type":31,"value":64},"The screen waits on a model.",{"type":25,"tag":60,"props":66,"children":67},{},[68],{"type":31,"value":69},"The model is slow (300ms on-device, 2–4 seconds in the cloud).",{"type":25,"tag":60,"props":71,"children":72},{},[73],{"type":31,"value":74},"The model can fail.",{"type":25,"tag":60,"props":76,"children":77},{},[78],{"type":31,"value":79},"The user shouldn't feel any of this.",{"type":25,"tag":34,"props":81,"children":82},{},[83,85,91],{"type":31,"value":84},"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 ",{"type":25,"tag":86,"props":87,"children":88},"em",{},[89],{"type":31,"value":90},"waiting to see who they became in the other universe",{"type":31,"value":92},". A spinner is not enough.",{"type":25,"tag":26,"props":94,"children":96},{"id":95},"the-pattern-that-finally-worked",[97],{"type":31,"value":98},"The pattern that finally worked",{"type":25,"tag":34,"props":100,"children":101},{},[102,104,110],{"type":31,"value":103},"After three rewrites, here's what stuck. I call it the ",{"type":25,"tag":105,"props":106,"children":107},"strong",{},[108],{"type":31,"value":109},"Anticipation–Stream–Commit",{"type":31,"value":111}," loop.",{"type":25,"tag":113,"props":114,"children":117},"pre",{"className":115,"code":116,"language":14,"meta":7,"style":7},"language-swift shiki shiki-themes dracula","@Observable\nfinal class EventState {\n    enum Phase {\n        case idle\n        case anticipating(String)\n        case streaming(partial: String)\n        case ready(Event)\n        case failed(Error)\n    }\n\n    var phase: Phase = .idle\n}\n",[118],{"type":25,"tag":119,"props":120,"children":121},"code",{"__ignoreMap":7},[122,133,142,151,160,169,178,187,196,205,214,223],{"type":25,"tag":123,"props":124,"children":127},"span",{"class":125,"line":126},"line",1,[128],{"type":25,"tag":123,"props":129,"children":130},{},[131],{"type":31,"value":132},"@Observable\n",{"type":25,"tag":123,"props":134,"children":136},{"class":125,"line":135},2,[137],{"type":25,"tag":123,"props":138,"children":139},{},[140],{"type":31,"value":141},"final class EventState {\n",{"type":25,"tag":123,"props":143,"children":145},{"class":125,"line":144},3,[146],{"type":25,"tag":123,"props":147,"children":148},{},[149],{"type":31,"value":150},"    enum Phase {\n",{"type":25,"tag":123,"props":152,"children":154},{"class":125,"line":153},4,[155],{"type":25,"tag":123,"props":156,"children":157},{},[158],{"type":31,"value":159},"        case idle\n",{"type":25,"tag":123,"props":161,"children":163},{"class":125,"line":162},5,[164],{"type":25,"tag":123,"props":165,"children":166},{},[167],{"type":31,"value":168},"        case anticipating(String)\n",{"type":25,"tag":123,"props":170,"children":172},{"class":125,"line":171},6,[173],{"type":25,"tag":123,"props":174,"children":175},{},[176],{"type":31,"value":177},"        case streaming(partial: String)\n",{"type":25,"tag":123,"props":179,"children":181},{"class":125,"line":180},7,[182],{"type":25,"tag":123,"props":183,"children":184},{},[185],{"type":31,"value":186},"        case ready(Event)\n",{"type":25,"tag":123,"props":188,"children":190},{"class":125,"line":189},8,[191],{"type":25,"tag":123,"props":192,"children":193},{},[194],{"type":31,"value":195},"        case failed(Error)\n",{"type":25,"tag":123,"props":197,"children":199},{"class":125,"line":198},9,[200],{"type":25,"tag":123,"props":201,"children":202},{},[203],{"type":31,"value":204},"    }\n",{"type":25,"tag":123,"props":206,"children":208},{"class":125,"line":207},10,[209],{"type":25,"tag":123,"props":210,"children":211},{"emptyLinePlaceholder":20},[212],{"type":31,"value":213},"\n",{"type":25,"tag":123,"props":215,"children":217},{"class":125,"line":216},11,[218],{"type":25,"tag":123,"props":219,"children":220},{},[221],{"type":31,"value":222},"    var phase: Phase = .idle\n",{"type":25,"tag":123,"props":224,"children":226},{"class":125,"line":225},12,[227],{"type":25,"tag":123,"props":228,"children":229},{},[230],{"type":31,"value":231},"}\n",{"type":25,"tag":34,"props":233,"children":234},{},[235],{"type":31,"value":236},"Each phase maps to a distinct SwiftUI view treatment:",{"type":25,"tag":56,"props":238,"children":239},{},[240,250,260,270],{"type":25,"tag":60,"props":241,"children":242},{},[243,248],{"type":25,"tag":105,"props":244,"children":245},{},[246],{"type":31,"value":247},"Anticipating:",{"type":31,"value":249}," the universe is \"thinking\" — a soft pulse animation, a quote from a previous moment, no spinner",{"type":25,"tag":60,"props":251,"children":252},{},[253,258],{"type":25,"tag":105,"props":254,"children":255},{},[256],{"type":31,"value":257},"Streaming:",{"type":31,"value":259}," tokens land on screen one at a time, slower than the model can produce them (this is intentional — see below)",{"type":25,"tag":60,"props":261,"children":262},{},[263,268],{"type":25,"tag":105,"props":264,"children":265},{},[266],{"type":31,"value":267},"Ready:",{"type":31,"value":269}," the event card is solid, tappable, real",{"type":25,"tag":60,"props":271,"children":272},{},[273,278],{"type":25,"tag":105,"props":274,"children":275},{},[276],{"type":31,"value":277},"Failed:",{"type":31,"value":279}," gentle, never an alert — \"the universe got quiet, try again\"",{"type":25,"tag":34,"props":281,"children":282},{},[283,285,291],{"type":31,"value":284},"The view becomes a state machine in ",{"type":25,"tag":119,"props":286,"children":288},{"className":287},[],[289],{"type":31,"value":290},"body",{"type":31,"value":292},":",{"type":25,"tag":113,"props":294,"children":296},{"className":115,"code":295,"language":14,"meta":7,"style":7},"var body: some View {\n    switch state.phase {\n    case .idle:              EmptyView()\n    case .anticipating(let q): AnticipationView(quote: q)\n    case .streaming(let p):  StreamingView(text: p)\n    case .ready(let event):  EventCard(event: event)\n    case .failed:            QuietRecoveryView()\n    }\n}\n",[297],{"type":25,"tag":119,"props":298,"children":299},{"__ignoreMap":7},[300,308,316,324,332,340,348,356,363],{"type":25,"tag":123,"props":301,"children":302},{"class":125,"line":126},[303],{"type":25,"tag":123,"props":304,"children":305},{},[306],{"type":31,"value":307},"var body: some View {\n",{"type":25,"tag":123,"props":309,"children":310},{"class":125,"line":135},[311],{"type":25,"tag":123,"props":312,"children":313},{},[314],{"type":31,"value":315},"    switch state.phase {\n",{"type":25,"tag":123,"props":317,"children":318},{"class":125,"line":144},[319],{"type":25,"tag":123,"props":320,"children":321},{},[322],{"type":31,"value":323},"    case .idle:              EmptyView()\n",{"type":25,"tag":123,"props":325,"children":326},{"class":125,"line":153},[327],{"type":25,"tag":123,"props":328,"children":329},{},[330],{"type":31,"value":331},"    case .anticipating(let q): AnticipationView(quote: q)\n",{"type":25,"tag":123,"props":333,"children":334},{"class":125,"line":162},[335],{"type":25,"tag":123,"props":336,"children":337},{},[338],{"type":31,"value":339},"    case .streaming(let p):  StreamingView(text: p)\n",{"type":25,"tag":123,"props":341,"children":342},{"class":125,"line":171},[343],{"type":25,"tag":123,"props":344,"children":345},{},[346],{"type":31,"value":347},"    case .ready(let event):  EventCard(event: event)\n",{"type":25,"tag":123,"props":349,"children":350},{"class":125,"line":180},[351],{"type":25,"tag":123,"props":352,"children":353},{},[354],{"type":31,"value":355},"    case .failed:            QuietRecoveryView()\n",{"type":25,"tag":123,"props":357,"children":358},{"class":125,"line":189},[359],{"type":25,"tag":123,"props":360,"children":361},{},[362],{"type":31,"value":204},{"type":25,"tag":123,"props":364,"children":365},{"class":125,"line":198},[366],{"type":25,"tag":123,"props":367,"children":368},{},[369],{"type":31,"value":231},{"type":25,"tag":34,"props":371,"children":372},{},[373,375,381],{"type":31,"value":374},"What I love about this: SwiftUI's diffing handles the transitions. No ",{"type":25,"tag":119,"props":376,"children":378},{"className":377},[],[379],{"type":31,"value":380},"withAnimation",{"type":31,"value":382}," calls scattered through the codebase. No \"did the spinner show?\" race conditions.",{"type":25,"tag":26,"props":384,"children":386},{"id":385},"slow-down-the-stream",[387],{"type":31,"value":388},"Slow down the stream",{"type":25,"tag":34,"props":390,"children":391},{},[392,394,399],{"type":31,"value":393},"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 ",{"type":25,"tag":86,"props":395,"children":396},{},[397],{"type":31,"value":398},"too fast",{"type":31,"value":400},". The user doesn't have time to feel the moment.",{"type":25,"tag":113,"props":402,"children":404},{"className":115,"code":403,"language":14,"meta":7,"style":7},"func reveal(_ text: String) async {\n    for token in tokens(in: text) {\n        state.phase = .streaming(partial: currentText + token)\n        try? await Task.sleep(for: .milliseconds(30))\n    }\n}\n",[405],{"type":25,"tag":119,"props":406,"children":407},{"__ignoreMap":7},[408,416,424,432,440,447],{"type":25,"tag":123,"props":409,"children":410},{"class":125,"line":126},[411],{"type":25,"tag":123,"props":412,"children":413},{},[414],{"type":31,"value":415},"func reveal(_ text: String) async {\n",{"type":25,"tag":123,"props":417,"children":418},{"class":125,"line":135},[419],{"type":25,"tag":123,"props":420,"children":421},{},[422],{"type":31,"value":423},"    for token in tokens(in: text) {\n",{"type":25,"tag":123,"props":425,"children":426},{"class":125,"line":144},[427],{"type":25,"tag":123,"props":428,"children":429},{},[430],{"type":31,"value":431},"        state.phase = .streaming(partial: currentText + token)\n",{"type":25,"tag":123,"props":433,"children":434},{"class":125,"line":153},[435],{"type":25,"tag":123,"props":436,"children":437},{},[438],{"type":31,"value":439},"        try? await Task.sleep(for: .milliseconds(30))\n",{"type":25,"tag":123,"props":441,"children":442},{"class":125,"line":162},[443],{"type":25,"tag":123,"props":444,"children":445},{},[446],{"type":31,"value":204},{"type":25,"tag":123,"props":448,"children":449},{"class":125,"line":171},[450],{"type":25,"tag":123,"props":451,"children":452},{},[453],{"type":31,"value":231},{"type":25,"tag":34,"props":455,"children":456},{},[457,459,464],{"type":31,"value":458},"30ms per token feels like writing. 5ms per token feels like a printer. The model is fast — the ",{"type":25,"tag":86,"props":460,"children":461},{},[462],{"type":31,"value":463},"reveal",{"type":31,"value":465}," is the design choice. Apps that show text instantly miss the whole point of narrative.",{"type":25,"tag":26,"props":467,"children":469},{"id":468},"caching-across-universes",[470],{"type":31,"value":471},"Caching across universes",{"type":25,"tag":34,"props":473,"children":474},{},[475],{"type":31,"value":476},"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.",{"type":25,"tag":34,"props":478,"children":479},{},[480],{"type":31,"value":481},"What I do instead: a tiered cache keyed by biography hash and recent-choice fingerprint.",{"type":25,"tag":113,"props":483,"children":485},{"className":115,"code":484,"language":14,"meta":7,"style":7},"struct EventCache {\n    let store: Cache\u003CEventKey, Event> = .init(limit: 200)\n\n    func event(for context: EventContext) async throws -> Event {\n        let key = EventKey(from: context)\n        if let hit = store[key] { return hit }\n\n        let event = try await ai.generate(context)\n        store[key] = event\n        return event\n    }\n}\n",[486],{"type":25,"tag":119,"props":487,"children":488},{"__ignoreMap":7},[489,497,505,512,520,528,536,543,551,559,567,574],{"type":25,"tag":123,"props":490,"children":491},{"class":125,"line":126},[492],{"type":25,"tag":123,"props":493,"children":494},{},[495],{"type":31,"value":496},"struct EventCache {\n",{"type":25,"tag":123,"props":498,"children":499},{"class":125,"line":135},[500],{"type":25,"tag":123,"props":501,"children":502},{},[503],{"type":31,"value":504},"    let store: Cache\u003CEventKey, Event> = .init(limit: 200)\n",{"type":25,"tag":123,"props":506,"children":507},{"class":125,"line":144},[508],{"type":25,"tag":123,"props":509,"children":510},{"emptyLinePlaceholder":20},[511],{"type":31,"value":213},{"type":25,"tag":123,"props":513,"children":514},{"class":125,"line":153},[515],{"type":25,"tag":123,"props":516,"children":517},{},[518],{"type":31,"value":519},"    func event(for context: EventContext) async throws -> Event {\n",{"type":25,"tag":123,"props":521,"children":522},{"class":125,"line":162},[523],{"type":25,"tag":123,"props":524,"children":525},{},[526],{"type":31,"value":527},"        let key = EventKey(from: context)\n",{"type":25,"tag":123,"props":529,"children":530},{"class":125,"line":171},[531],{"type":25,"tag":123,"props":532,"children":533},{},[534],{"type":31,"value":535},"        if let hit = store[key] { return hit }\n",{"type":25,"tag":123,"props":537,"children":538},{"class":125,"line":180},[539],{"type":25,"tag":123,"props":540,"children":541},{"emptyLinePlaceholder":20},[542],{"type":31,"value":213},{"type":25,"tag":123,"props":544,"children":545},{"class":125,"line":189},[546],{"type":25,"tag":123,"props":547,"children":548},{},[549],{"type":31,"value":550},"        let event = try await ai.generate(context)\n",{"type":25,"tag":123,"props":552,"children":553},{"class":125,"line":198},[554],{"type":25,"tag":123,"props":555,"children":556},{},[557],{"type":31,"value":558},"        store[key] = event\n",{"type":25,"tag":123,"props":560,"children":561},{"class":125,"line":207},[562],{"type":25,"tag":123,"props":563,"children":564},{},[565],{"type":31,"value":566},"        return event\n",{"type":25,"tag":123,"props":568,"children":569},{"class":125,"line":216},[570],{"type":25,"tag":123,"props":571,"children":572},{},[573],{"type":31,"value":204},{"type":25,"tag":123,"props":575,"children":576},{"class":125,"line":225},[577],{"type":25,"tag":123,"props":578,"children":579},{},[580],{"type":31,"value":231},{"type":25,"tag":34,"props":582,"children":583},{},[584],{"type":31,"value":585},"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.",{"type":25,"tag":26,"props":587,"children":589},{"id":588},"what-observable-changed-for-me",[590],{"type":31,"value":591},"What @Observable changed for me",{"type":25,"tag":34,"props":593,"children":594},{},[595,601,603,609,611,617],{"type":25,"tag":119,"props":596,"children":598},{"className":597},[],[599],{"type":31,"value":600},"@Observable",{"type":31,"value":602}," (the macro-based observation system) is the single biggest SwiftUI improvement of the past two years. I no longer write ",{"type":25,"tag":119,"props":604,"children":606},{"className":605},[],[607],{"type":31,"value":608},"@Published",{"type":31,"value":610}," on a thousand properties. I no longer fight ",{"type":25,"tag":119,"props":612,"children":614},{"className":613},[],[615],{"type":31,"value":616},"ObservableObject",{"type":31,"value":618}," re-render storms. The view only re-renders when the property it reads actually changes.",{"type":25,"tag":34,"props":620,"children":621},{},[622],{"type":31,"value":623},"The pattern:",{"type":25,"tag":113,"props":625,"children":627},{"className":115,"code":626,"language":14,"meta":7,"style":7},"@Observable\nfinal class UniverseStore {\n    var current: Universe?\n    var map: UniverseMap = .empty\n    var credits: Int = 5\n}\n",[628],{"type":25,"tag":119,"props":629,"children":630},{"__ignoreMap":7},[631,638,646,654,662,670],{"type":25,"tag":123,"props":632,"children":633},{"class":125,"line":126},[634],{"type":25,"tag":123,"props":635,"children":636},{},[637],{"type":31,"value":132},{"type":25,"tag":123,"props":639,"children":640},{"class":125,"line":135},[641],{"type":25,"tag":123,"props":642,"children":643},{},[644],{"type":31,"value":645},"final class UniverseStore {\n",{"type":25,"tag":123,"props":647,"children":648},{"class":125,"line":144},[649],{"type":25,"tag":123,"props":650,"children":651},{},[652],{"type":31,"value":653},"    var current: Universe?\n",{"type":25,"tag":123,"props":655,"children":656},{"class":125,"line":153},[657],{"type":25,"tag":123,"props":658,"children":659},{},[660],{"type":31,"value":661},"    var map: UniverseMap = .empty\n",{"type":25,"tag":123,"props":663,"children":664},{"class":125,"line":162},[665],{"type":25,"tag":123,"props":666,"children":667},{},[668],{"type":31,"value":669},"    var credits: Int = 5\n",{"type":25,"tag":123,"props":671,"children":672},{"class":125,"line":171},[673],{"type":25,"tag":123,"props":674,"children":675},{},[676],{"type":31,"value":231},{"type":25,"tag":34,"props":678,"children":679},{},[680,682,688],{"type":31,"value":681},"In a view that reads only ",{"type":25,"tag":119,"props":683,"children":685},{"className":684},[],[686],{"type":31,"value":687},"credits",{"type":31,"value":292},{"type":25,"tag":113,"props":690,"children":692},{"className":115,"code":691,"language":14,"meta":7,"style":7},"@Environment(UniverseStore.self) private var store\n\nvar body: some View {\n    Text(\"\\(store.credits) splits left\")\n}\n",[693],{"type":25,"tag":119,"props":694,"children":695},{"__ignoreMap":7},[696,704,711,718,726],{"type":25,"tag":123,"props":697,"children":698},{"class":125,"line":126},[699],{"type":25,"tag":123,"props":700,"children":701},{},[702],{"type":31,"value":703},"@Environment(UniverseStore.self) private var store\n",{"type":25,"tag":123,"props":705,"children":706},{"class":125,"line":135},[707],{"type":25,"tag":123,"props":708,"children":709},{"emptyLinePlaceholder":20},[710],{"type":31,"value":213},{"type":25,"tag":123,"props":712,"children":713},{"class":125,"line":144},[714],{"type":25,"tag":123,"props":715,"children":716},{},[717],{"type":31,"value":307},{"type":25,"tag":123,"props":719,"children":720},{"class":125,"line":153},[721],{"type":25,"tag":123,"props":722,"children":723},{},[724],{"type":31,"value":725},"    Text(\"\\(store.credits) splits left\")\n",{"type":25,"tag":123,"props":727,"children":728},{"class":125,"line":162},[729],{"type":25,"tag":123,"props":730,"children":731},{},[732],{"type":31,"value":231},{"type":25,"tag":34,"props":734,"children":735},{},[736,738,744,746,751,753,759],{"type":31,"value":737},"When ",{"type":25,"tag":119,"props":739,"children":741},{"className":740},[],[742],{"type":31,"value":743},"current",{"type":31,"value":745}," changes but ",{"type":25,"tag":119,"props":747,"children":749},{"className":748},[],[750],{"type":31,"value":687},{"type":31,"value":752}," doesn't, this view doesn't re-render. With the old ",{"type":25,"tag":119,"props":754,"children":756},{"className":755},[],[757],{"type":31,"value":758},"@ObservableObject",{"type":31,"value":760},", it would have. Six months in, I cannot overstate how much smoother my app feels for this one change.",{"type":25,"tag":26,"props":762,"children":764},{"id":763},"state-persistence-isar-to-swiftdata",[765],{"type":31,"value":766},"State persistence: Isar to SwiftData",{"type":25,"tag":34,"props":768,"children":769},{},[770,772,777],{"type":31,"value":771},"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 ",{"type":25,"tag":119,"props":773,"children":775},{"className":774},[],[776],{"type":31,"value":600},{"type":31,"value":778}," is seamless, and the model definitions are pure Swift.",{"type":25,"tag":113,"props":780,"children":782},{"className":115,"code":781,"language":14,"meta":7,"style":7},"@Model\nfinal class Universe {\n    var id: UUID\n    var biography: String\n    var choices: [Choice]\n    var createdAt: Date\n\n    init(biography: String) {\n        self.id = UUID()\n        self.biography = biography\n        self.choices = []\n        self.createdAt = .now\n    }\n}\n",[783],{"type":25,"tag":119,"props":784,"children":785},{"__ignoreMap":7},[786,794,802,810,818,826,834,841,849,857,865,873,881,889],{"type":25,"tag":123,"props":787,"children":788},{"class":125,"line":126},[789],{"type":25,"tag":123,"props":790,"children":791},{},[792],{"type":31,"value":793},"@Model\n",{"type":25,"tag":123,"props":795,"children":796},{"class":125,"line":135},[797],{"type":25,"tag":123,"props":798,"children":799},{},[800],{"type":31,"value":801},"final class Universe {\n",{"type":25,"tag":123,"props":803,"children":804},{"class":125,"line":144},[805],{"type":25,"tag":123,"props":806,"children":807},{},[808],{"type":31,"value":809},"    var id: UUID\n",{"type":25,"tag":123,"props":811,"children":812},{"class":125,"line":153},[813],{"type":25,"tag":123,"props":814,"children":815},{},[816],{"type":31,"value":817},"    var biography: String\n",{"type":25,"tag":123,"props":819,"children":820},{"class":125,"line":162},[821],{"type":25,"tag":123,"props":822,"children":823},{},[824],{"type":31,"value":825},"    var choices: [Choice]\n",{"type":25,"tag":123,"props":827,"children":828},{"class":125,"line":171},[829],{"type":25,"tag":123,"props":830,"children":831},{},[832],{"type":31,"value":833},"    var createdAt: Date\n",{"type":25,"tag":123,"props":835,"children":836},{"class":125,"line":180},[837],{"type":25,"tag":123,"props":838,"children":839},{"emptyLinePlaceholder":20},[840],{"type":31,"value":213},{"type":25,"tag":123,"props":842,"children":843},{"class":125,"line":189},[844],{"type":25,"tag":123,"props":845,"children":846},{},[847],{"type":31,"value":848},"    init(biography: String) {\n",{"type":25,"tag":123,"props":850,"children":851},{"class":125,"line":198},[852],{"type":25,"tag":123,"props":853,"children":854},{},[855],{"type":31,"value":856},"        self.id = UUID()\n",{"type":25,"tag":123,"props":858,"children":859},{"class":125,"line":207},[860],{"type":25,"tag":123,"props":861,"children":862},{},[863],{"type":31,"value":864},"        self.biography = biography\n",{"type":25,"tag":123,"props":866,"children":867},{"class":125,"line":216},[868],{"type":25,"tag":123,"props":869,"children":870},{},[871],{"type":31,"value":872},"        self.choices = []\n",{"type":25,"tag":123,"props":874,"children":875},{"class":125,"line":225},[876],{"type":25,"tag":123,"props":877,"children":878},{},[879],{"type":31,"value":880},"        self.createdAt = .now\n",{"type":25,"tag":123,"props":882,"children":884},{"class":125,"line":883},13,[885],{"type":25,"tag":123,"props":886,"children":887},{},[888],{"type":31,"value":204},{"type":25,"tag":123,"props":890,"children":892},{"class":125,"line":891},14,[893],{"type":25,"tag":123,"props":894,"children":895},{},[896],{"type":31,"value":231},{"type":25,"tag":34,"props":898,"children":899},{},[900],{"type":31,"value":901},"Fetch from a view:",{"type":25,"tag":113,"props":903,"children":905},{"className":115,"code":904,"language":14,"meta":7,"style":7},"@Query(sort: \\Universe.createdAt, order: .reverse)\nprivate var universes: [Universe]\n",[906],{"type":25,"tag":119,"props":907,"children":908},{"__ignoreMap":7},[909,917],{"type":25,"tag":123,"props":910,"children":911},{"class":125,"line":126},[912],{"type":25,"tag":123,"props":913,"children":914},{},[915],{"type":31,"value":916},"@Query(sort: \\Universe.createdAt, order: .reverse)\n",{"type":25,"tag":123,"props":918,"children":919},{"class":125,"line":135},[920],{"type":25,"tag":123,"props":921,"children":922},{},[923],{"type":31,"value":924},"private var universes: [Universe]\n",{"type":25,"tag":34,"props":926,"children":927},{},[928,930,936],{"type":31,"value":929},"It feels like SwiftUI's ",{"type":25,"tag":119,"props":931,"children":933},{"className":932},[],[934],{"type":31,"value":935},"@State",{"type":31,"value":937}," for persistent data. Which is what we wanted all along.",{"type":25,"tag":26,"props":939,"children":941},{"id":940},"the-bug-id-warn-you-about",[942],{"type":31,"value":943},"The bug I'd warn you about",{"type":25,"tag":34,"props":945,"children":946},{},[947,949,954,956,960],{"type":31,"value":948},"One subtle thing: SwiftUI re-evaluates ",{"type":25,"tag":119,"props":950,"children":952},{"className":951},[],[953],{"type":31,"value":290},{"type":31,"value":955}," when state changes, but the ",{"type":25,"tag":86,"props":957,"children":958},{},[959],{"type":31,"value":290},{"type":31,"value":961}," itself shouldn't kick off async work. The first version of Elsewhere had this:",{"type":25,"tag":113,"props":963,"children":965},{"className":115,"code":964,"language":14,"meta":7,"style":7},"var body: some View {\n    EventCard(event: event)\n        .onAppear {\n            Task { await state.generateNext() }  // ❌\n        }\n}\n",[966],{"type":25,"tag":119,"props":967,"children":968},{"__ignoreMap":7},[969,976,984,992,1000,1008],{"type":25,"tag":123,"props":970,"children":971},{"class":125,"line":126},[972],{"type":25,"tag":123,"props":973,"children":974},{},[975],{"type":31,"value":307},{"type":25,"tag":123,"props":977,"children":978},{"class":125,"line":135},[979],{"type":25,"tag":123,"props":980,"children":981},{},[982],{"type":31,"value":983},"    EventCard(event: event)\n",{"type":25,"tag":123,"props":985,"children":986},{"class":125,"line":144},[987],{"type":25,"tag":123,"props":988,"children":989},{},[990],{"type":31,"value":991},"        .onAppear {\n",{"type":25,"tag":123,"props":993,"children":994},{"class":125,"line":153},[995],{"type":25,"tag":123,"props":996,"children":997},{},[998],{"type":31,"value":999},"            Task { await state.generateNext() }  // ❌\n",{"type":25,"tag":123,"props":1001,"children":1002},{"class":125,"line":162},[1003],{"type":25,"tag":123,"props":1004,"children":1005},{},[1006],{"type":31,"value":1007},"        }\n",{"type":25,"tag":123,"props":1009,"children":1010},{"class":125,"line":171},[1011],{"type":25,"tag":123,"props":1012,"children":1013},{},[1014],{"type":31,"value":231},{"type":25,"tag":34,"props":1016,"children":1017},{},[1018,1020,1026,1028,1033],{"type":31,"value":1019},"That ",{"type":25,"tag":119,"props":1021,"children":1023},{"className":1022},[],[1024],{"type":31,"value":1025},"onAppear",{"type":31,"value":1027}," fires every time the view re-enters the hierarchy. Which in a map-of-universes app, is ",{"type":25,"tag":86,"props":1029,"children":1030},{},[1031],{"type":31,"value":1032},"constantly",{"type":31,"value":1034},". I burned hundreds of dollars in unnecessary Gemini calls before catching it.",{"type":25,"tag":34,"props":1036,"children":1037},{},[1038,1040,1046],{"type":31,"value":1039},"Use ",{"type":25,"tag":119,"props":1041,"children":1043},{"className":1042},[],[1044],{"type":31,"value":1045},".task(id:)",{"type":31,"value":1047}," instead:",{"type":25,"tag":113,"props":1049,"children":1051},{"className":115,"code":1050,"language":14,"meta":7,"style":7},".task(id: universe.id) {\n    await state.generateNext()\n}\n",[1052],{"type":25,"tag":119,"props":1053,"children":1054},{"__ignoreMap":7},[1055,1063,1071],{"type":25,"tag":123,"props":1056,"children":1057},{"class":125,"line":126},[1058],{"type":25,"tag":123,"props":1059,"children":1060},{},[1061],{"type":31,"value":1062},".task(id: universe.id) {\n",{"type":25,"tag":123,"props":1064,"children":1065},{"class":125,"line":135},[1066],{"type":25,"tag":123,"props":1067,"children":1068},{},[1069],{"type":31,"value":1070},"    await state.generateNext()\n",{"type":25,"tag":123,"props":1072,"children":1073},{"class":125,"line":144},[1074],{"type":25,"tag":123,"props":1075,"children":1076},{},[1077],{"type":31,"value":231},{"type":25,"tag":34,"props":1079,"children":1080},{},[1081,1083,1089],{"type":31,"value":1082},"This runs once per ",{"type":25,"tag":119,"props":1084,"children":1086},{"className":1085},[],[1087],{"type":31,"value":1088},"universe.id",{"type":31,"value":1090},", cancels cleanly when the view goes away, and never double-fires. Use it for anything async tied to view appearance.",{"type":25,"tag":26,"props":1092,"children":1094},{"id":1093},"what-id-tell-past-me",[1095],{"type":31,"value":1096},"What I'd tell past-me",{"type":25,"tag":1098,"props":1099,"children":1100},"ol",{},[1101,1106,1111,1116,1142],{"type":25,"tag":60,"props":1102,"children":1103},{},[1104],{"type":31,"value":1105},"Pick on-device first, cloud as fallback. Cost compounds invisibly.",{"type":25,"tag":60,"props":1107,"children":1108},{},[1109],{"type":31,"value":1110},"Treat AI latency as a design problem, not an engineering problem.",{"type":25,"tag":60,"props":1112,"children":1113},{},[1114],{"type":31,"value":1115},"Don't let the LLM dictate your view architecture — wrap it in state machines.",{"type":25,"tag":60,"props":1117,"children":1118},{},[1119,1121,1126,1128,1133,1135,1140],{"type":31,"value":1120},"Trust ",{"type":25,"tag":119,"props":1122,"children":1124},{"className":1123},[],[1125],{"type":31,"value":600},{"type":31,"value":1127},". Trust ",{"type":25,"tag":119,"props":1129,"children":1131},{"className":1130},[],[1132],{"type":31,"value":1045},{"type":31,"value":1134},". Stop using ",{"type":25,"tag":119,"props":1136,"children":1138},{"className":1137},[],[1139],{"type":31,"value":1025},{"type":31,"value":1141}," for anything async.",{"type":25,"tag":60,"props":1143,"children":1144},{},[1145],{"type":31,"value":1146},"Cache aggressively. Users won't notice; your wallet will.",{"type":25,"tag":34,"props":1148,"children":1149},{},[1150],{"type":31,"value":1151},"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.",{"type":25,"tag":34,"props":1153,"children":1154},{},[1155],{"type":31,"value":1156},"— Met",{"type":25,"tag":1158,"props":1159,"children":1160},"style",{},[1161],{"type":31,"value":1162},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":7,"searchDepth":135,"depth":135,"links":1164},[1165,1166,1167,1168,1169,1170,1171,1172,1173],{"id":28,"depth":144,"text":32},{"id":46,"depth":144,"text":49},{"id":95,"depth":144,"text":98},{"id":385,"depth":144,"text":388},{"id":468,"depth":144,"text":471},{"id":588,"depth":144,"text":591},{"id":763,"depth":144,"text":766},{"id":940,"depth":144,"text":943},{"id":1093,"depth":144,"text":1096},"markdown","content:blogs:9. swiftui-ai-narrative-apps-april-2026.md","content","blogs/9. swiftui-ai-narrative-apps-april-2026.md","blogs/9. swiftui-ai-narrative-apps-april-2026","md",{"loc":4},1780164678385]