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