Skip to main content

Write SwiftUI With AI: 5 Prompt Templates and 8 Pitfalls

When AI writes SwiftUI, most bugs are APIs above your minimum iOS, mixed ObservableObject and @Observable, and side effects in body. 5 prompts, 8 fixes.

When you write SwiftUI with AI, most mistakes aren’t syntax. They’re APIs newer than your minimum iOS version, ObservableObject and @Observable mixed together, and side effects such as network requests placed in body. The fix is to state the minimum iOS version, the state management approach and what must not change in your prompt, then check that the code compiles and test it on an iPhone. This article gives five prompt templates you can copy and eight common pitfalls, each with the wrong and the right code.

For the full workflow with an AI assistant in an iOS project, see AI iOS app development in VS Code with Copilot and Claude Code. Long-term project conventions belong in an instructions file, covered in AI instructions file templates. This article covers single-conversation prompts and the SwiftUI code itself.

What four things must a SwiftUI prompt spell out?

  • The minimum iOS version. By default the AI generates the newest patterns it has seen most. State “minimum iOS 15.0” and it avoids NavigationStack and @Observable.
  • The state management approach. Say whether to use ObservableObject + @Published or @Observable, and never mix the two.
  • Where the code goes and what’s off limits. Give file and type names, and say things like “don’t change how XxxStore stores data” or “don’t add third-party libraries”.
  • What done means. Ask for code that compiles, and for a list of interactions to verify on the phone.

In the templates below, replace the bracketed parts with your own. If your project already has an instructions file, delete the lines that repeat it.

Template 1: new screen

1Create a SwiftUI settings screen, SettingsView, in [Views/SettingsView.swift].
2Project constraints: minimum iOS [15.0], Swift 5, state management with ObservableObject + @Published. Don't use APIs that need iOS [16] or later. If one is really needed, wrap it in if #available and provide the older code path.
3Screen content:
41. A Form with two sections, "Account" and "General".
52. The Account section shows the nickname (read-only) and a "Sign Out" button that shows a confirmation dialog when tapped.
63. The General section has a "Dark Mode" toggle whose value is saved in UserDefaults under the key "darkMode".
7Requirements: no third-party libraries, don't change other files. Put any #Preview inside #if DEBUG. Finish with a list of interactions I should verify on the phone.

Template 2: list + detail

 1Build an order list screen and a detail screen.
 2Project constraints: minimum iOS [16.0], navigation with NavigationStack + navigationDestination(for:), state management with ObservableObject.
 3Data: the Order struct already exists in [Models/Order.swift] with id, title, amount and createdAt, and conforms to Identifiable. Don't change its fields.
 4Data source: the OrderService protocol in [Services/OrderService.swift] has func fetchOrders() async throws -> [Order]. The screen receives it as an initializer parameter. Don't give the parameter a default value.
 5List screen:
 61. Load with .task when the screen appears. Show a ProgressView while loading, and an error message with a "Retry" button on failure.
 72. Support pull to refresh.
 83. ForEach uses Order's id directly, not indices or \.self.
 9Detail screen: show every field, with the amount formatted in the current locale's currency.
10Requirements: no network requests in body. Put sample data and the mock OrderService inside #if DEBUG, for #Preview only.

Template 3: form validation

 1Write a sign-up form in [Views/RegisterView.swift].
 2Project constraints: minimum iOS [15.0], state management with ObservableObject + @Published, ViewModel marked @MainActor.
 3Fields and rules:
 41. Email: a valid email address format.
 52. Password: 8 to 20 characters, containing both letters and digits.
 63. Confirm password: matches the password.
 7Interaction:
 81. Show a field's error only after the user leaves that field, as small red text below the input.
 92. Enable the "Sign Up" button only when every field is valid. While submitting, show a loading state on the button to prevent double taps.
103. Put the validation logic in the ViewModel as pure functions that don't depend on SwiftUI, so they can be tested on their own.
11Requirements: no third-party libraries. Leave an async placeholder function for the submit API.

Template 4: migrate from UIKit

1Rewrite [ProfileViewController.swift] as a SwiftUI ProfileView. The full original file is below.
2[Paste the original code]
3Project constraints: minimum iOS [15.0]. The other screens stay UIKit, and the new screen is embedded with UIHostingController. The entry point is showProfile() in [MainCoordinator.swift]. Change only that function.
4Migration requirements:
51. Behave the same as the original screen, including the refresh in viewWillAppear, the navigation bar buttons and the empty state.
62. Turn the original delegate callbacks into closure parameters passed to ProfileView.
73. Keep using the existing ProfileService without modifying it.
84. Keep the original file. Don't delete it.
9Finish with a list of places where the two screens might behave differently, so I can compare them one by one on the phone.

Template 5: fix a build error

1Below is a build error. It's only the first one. Fix only what this error causes, and don't refactor other code along the way.
2[Paste the full error, for example MyApp\Views\OrderListView.swift:12:9: error: 'NavigationStack' is only available in iOS 16.0 or newer]
3Relevant code:
4[Paste the failing function or the whole file]
5Project constraints: minimum iOS [15.0]. The deployment target can't be raised.
6Explain the cause, give the complete fixed code, and tell me whether the change affects other screens.

When one error causes a cascade, give only the first. The rest usually go away with it. For the exact concurrency errors and their fixes, see common Swift 6 strict concurrency errors.

Pitfall 1: APIs newer than the deployment target

The AI most often writes NavigationStack, @Observable and SwiftData while the project’s minimum is iOS 15. The build reports errors like these.

1error: 'NavigationStack' is only available in iOS 16.0 or newer
2error: 'navigationDestination(for:destination:)' is only available in iOS 16.0 or newer

Wrong.

 1struct ContentView: View {
 2    var body: some View {
 3        NavigationStack {
 4            List(1..<4) { n in
 5                NavigationLink("Item \(n)", value: n)
 6            }
 7            .navigationDestination(for: Int.self) { n in
 8                Text("Detail \(n)")
 9            }
10        }
11    }
12}

Right. Either raise the deployment target, or use if #available to keep a path for older versions.

 1struct ContentView: View {
 2    var body: some View {
 3        if #available(iOS 16.0, *) {
 4            NavigationStack {
 5                list
 6            }
 7        } else {
 8            NavigationView {
 9                list
10            }
11        }
12    }
13
14    private var list: some View {
15        List(1..<4) { n in
16            NavigationLink("Item \(n)") {
17                Text("Detail \(n)")
18            }
19        }
20    }
21}

The minimum versions of common APIs are in the table at the end.

Pitfall 2: mixing ObservableObject and @Observable

@Observable is the iOS 17 approach, paired with @State, @Bindable and @Environment(Type.self). The AI often combines it with the older @StateObject, @ObservedObject and @EnvironmentObject.

1error: generic struct 'StateObject' requires that 'CartModel' conform to 'ObservableObject'

Wrong.

 1@Observable
 2final class CartModel {
 3    var items: [String] = []
 4}
 5
 6struct CartView: View {
 7    @StateObject private var model = CartModel()
 8
 9    var body: some View {
10        Text("\(model.items.count) items")
11    }
12}

With a minimum of iOS 17, switch everything to the new approach.

 1@Observable
 2final class CartModel {
 3    var items: [String] = []
 4}
 5
 6struct CartView: View {
 7    @State private var model = CartModel()
 8
 9    var body: some View {
10        VStack {
11            Text("\(model.items.count) items")
12            CartEditor(model: model)
13        }
14        .environment(model)
15    }
16}
17
18struct CartEditor: View {
19    @Bindable var model: CartModel
20
21    var body: some View {
22        Button("Add one") {
23            model.items.append("Product")
24        }
25    }
26}
27
28struct CartBadge: View {
29    @Environment(CartModel.self) private var model
30
31    var body: some View {
32        Text("\(model.items.count)")
33    }
34}

With a minimum of iOS 16 or lower, use the older approach throughout. The class conforms to ObservableObject, properties get @Published, the owner uses @StateObject, child views take @ObservedObject, and the environment uses .environmentObject(model) with @EnvironmentObject. Apple maps one approach to the other in Migrating from the Observable Object protocol to the Observable macro.

Pitfall 3: why shouldn’t body have side effects?

body is recomputed on every state change, so sending requests, writing files or changing state inside it runs many times. Changing @State directly while body is being computed also triggers the runtime warning Modifying state during view update.

Wrong. It compiles, but every UI refresh sends another request.

 1struct ProfileView: View {
 2    let userID: String
 3    @State private var name = ""
 4
 5    var body: some View {
 6        let _ = Task {
 7            name = await fetchName(userID)
 8        }
 9        Text(name.isEmpty ? "Loading" : name)
10    }
11}

Right. Use .task(id:). It runs once when the view appears, cancels the old task and runs again when userID changes, and cancels when the view disappears.

 1struct ProfileView: View {
 2    let userID: String
 3    @State private var name = ""
 4
 5    var body: some View {
 6        Text(name.isEmpty ? "Loading" : name)
 7            .task(id: userID) {
 8                name = await fetchName(userID)
 9            }
10    }
11}
12
13func fetchName(_ id: String) async -> String {
14    // Call the API
15    "Alex"
16}

Put tap-triggered work in the button’s action and reactions to data changes in .onChange, never in the computation of body.

Pitfall 4: what goes wrong with unstable ForEach ids?

SwiftUI uses ids to tell rows apart. If ids change every time, animations, text field focus and @State inside rows are lost. If ids repeat, deletes and refreshes show the wrong rows.

Wrong. The computed property returns a new UUID every time, and \.self repeats when the string array has duplicate values.

 1struct Todo {
 2    var title: String
 3    var id: UUID { UUID() }
 4}
 5
 6struct TodoList: View {
 7    let todos: [Todo]
 8    let tags: [String]
 9
10    var body: some View {
11        List {
12            ForEach(todos, id: \.id) { todo in
13                Text(todo.title)
14            }
15            ForEach(tags, id: \.self) { tag in
16                Text(tag)
17            }
18        }
19    }
20}

Right. As a stored property, the id is generated once and saved with the data.

 1struct Todo: Identifiable, Codable {
 2    var id = UUID()
 3    var title: String
 4}
 5
 6struct TodoList: View {
 7    @State private var todos: [Todo] = []
 8
 9    var body: some View {
10        List {
11            ForEach(todos) { todo in
12                Text(todo.title)
13            }
14            .onDelete { offsets in
15                todos.remove(atOffsets: offsets)
16            }
17        }
18    }
19}

Also avoid index-based loops like ForEach(todos.indices, id: \.self). After a row is deleted the indices no longer line up, which easily leads to out-of-range crashes.

Pitfall 5: can @State hold a value passed in from outside?

@State takes its initial value only when the view is first created. New values the parent passes in later have no effect, and changes in the child don’t reach the parent.

Wrong.

 1struct EditNameView: View {
 2    @State var name: String
 3
 4    var body: some View {
 5        TextField("Name", text: $name)
 6    }
 7}
 8
 9struct SettingsView: View {
10    @State private var userName = "Alex"
11
12    var body: some View {
13        VStack {
14            Text("Current: \(userName)")
15            EditNameView(name: userName)
16        }
17    }
18}

Right. Use @Binding for values that must stay in sync with the parent, and have the parent pass $userName.

 1struct EditNameView: View {
 2    @Binding var name: String
 3
 4    var body: some View {
 5        TextField("Name", text: $name)
 6    }
 7}
 8
 9struct SettingsView: View {
10    @State private var userName = "Alex"
11
12    var body: some View {
13        VStack {
14            Text("Current: \(userName)")
15            EditNameView(name: $userName)
16        }
17    }
18}

If you really want a local draft that’s only committed on Save, make the @State private and copy the passed-in value in .onAppear.

Pitfall 6: how do you stop updating UI state from a background thread?

With callback-based network requests, the callback runs on a background thread and assigns straight to a @Published property. At runtime you get Publishing changes from background threads is not allowed, and the UI may not refresh or may show problems that are hard to reproduce.

Wrong.

 1final class FeedModel: ObservableObject {
 2    @Published var items: [String] = []
 3
 4    func load() {
 5        let url = URL(string: "https://api.example.com/feed")!
 6        URLSession.shared.dataTask(with: url) { data, _, _ in
 7            let lines = String(decoding: data ?? Data(), as: UTF8.self)
 8                .split(separator: "\n").map(String.init)
 9            self.items = lines
10        }.resume()
11    }
12}

Right. Mark the ViewModel @MainActor and use async/await. After await returns, you’re still on the main thread.

 1@MainActor
 2final class FeedModel: ObservableObject {
 3    @Published var items: [String] = []
 4
 5    func load() async {
 6        let url = URL(string: "https://api.example.com/feed")!
 7        do {
 8            let (data, _) = try await URLSession.shared.data(from: url)
 9            items = String(decoding: data, as: UTF8.self)
10                .split(separator: "\n").map(String.init)
11        } catch {
12            items = []
13        }
14    }
15}
16
17struct FeedView: View {
18    @StateObject private var model = FeedModel()
19
20    var body: some View {
21        List(model.items, id: \.self) { Text($0) }
22            .task { await model.load() }
23    }
24}

Pitfall 7: what happens without usage descriptions?

The AI writes code for location, the camera, the microphone or contacts but doesn’t touch Info.plist. Without a usage description, the app crashes as soon as it accesses that data, and App Review rejects it.

Wrong. The code itself is fine, but Info.plist has no matching key.

 1import CoreLocation
 2
 3@MainActor
 4final class LocationPermission: ObservableObject {
 5    private let manager = CLLocationManager()
 6
 7    func request() {
 8        manager.requestWhenInUseAuthorization()
 9    }
10}

Right. Add the usage description to Info.plist.

1<key>NSLocationWhenInUseUsageDescription</key>
2<string>Used to show stores near you</string>

For projects with a generated Info.plist, set the build setting INFOPLIST_KEY_NSLocationWhenInUseUsageDescription instead. The key for each permission is listed in Info.plist usage descriptions for every permission. If users only need to pick a few photos, use PhotosPicker (iOS 16 and later). The system picker doesn’t need photo library permission. Add a line to your prompt such as “When you use a feature that needs user authorization, list the Info.plist keys and user-facing text to add.”

Pitfall 8: preview code in the release build

To make #Preview work, the AI writes sample data and mock services, and sometimes makes the mock the default value of a parameter. If this code isn’t inside #if DEBUG, it’s compiled into the release build. In the worst case, users see test data.

Wrong.

 1struct MockOrderService: OrderService {
 2    func fetchOrders() async throws -> [Order] {
 3        [Order(id: 1, title: "Test order")]
 4    }
 5}
 6
 7struct OrderListView: View {
 8    var service: any OrderService = MockOrderService()
 9    @State private var orders: [Order] = []
10
11    var body: some View {
12        List(orders) { Text($0.title) }
13            .task { orders = (try? await service.fetchOrders()) ?? [] }
14    }
15}
16
17#Preview {
18    OrderListView()
19}

Right. Production code has no default value, so callers pass in the real service. The mock goes inside #if DEBUG together with the preview.

 1struct Order: Identifiable, Decodable {
 2    var id: Int
 3    var title: String
 4}
 5
 6protocol OrderService: Sendable {
 7    func fetchOrders() async throws -> [Order]
 8}
 9
10struct LiveOrderService: OrderService {
11    func fetchOrders() async throws -> [Order] {
12        let url = URL(string: "https://api.example.com/orders")!
13        let (data, _) = try await URLSession.shared.data(from: url)
14        return try JSONDecoder().decode([Order].self, from: data)
15    }
16}
17
18struct OrderListView: View {
19    let service: any OrderService
20    @State private var orders: [Order] = []
21
22    var body: some View {
23        List(orders) { Text($0.title) }
24            .task { orders = (try? await service.fetchOrders()) ?? [] }
25    }
26}
27
28#if DEBUG
29struct MockOrderService: OrderService {
30    func fetchOrders() async throws -> [Order] {
31        [Order(id: 1, title: "Test order")]
32    }
33}
34
35#Preview {
36    OrderListView(service: MockOrderService())
37}
38#endif

After the change, build once with the Release configuration to confirm no production code references types inside #if DEBUG.

How do you verify SwiftUI code written with AI?

  1. Watch the editor. With KXBuild code completion working, problems in code the AI changed get a red underline right away. See Code completion and indexing.
  2. Check that it compiles. Run the command below in the project folder. It only checks compilation and skips signing. An AI assistant that can run terminal commands can run it itself and keep fixing errors.
1kxbuild build .\MyApp.xcodeproj -D CODE_SIGNING_ALLOWED=NO
  1. Build Release once more. Confirm nothing outside #if DEBUG references preview code.
1kxbuild build .\MyApp.xcodeproj -c Release --no-ipa -D CODE_SIGNING_ALLOWED=NO
  1. Press F5 for your iPhone. KXBuild builds, signs, installs and launches the app, and print output appears in Output > KXBuild Console. Go through the interactions the AI listed, then try rotating the screen, switching to the background and back, and loading on a weak network. The AI misses these most often.
  2. Test on an older iOS version. If you have an iPhone on your minimum supported version, install there too. The other branch of if #available only runs on that device.

For breakpoints and inspecting variables, see Run and debug.

Which iOS version does each common SwiftUI API need?

Versions are from each API’s page in Apple’s developer documentation, as of October 2026.

API Minimum iOS Alternative for older versions
@StateObject 14.0 @ObservedObject, owned elsewhere
AsyncImage 15.0 Load with URLSession yourself
.refreshable 15.0 None
foregroundStyle(_:) 15.0 foregroundColor(_:)
NavigationStack, navigationDestination(for:) 16.0 NavigationView + NavigationLink(destination:)
NavigationSplitView 16.0 NavigationView
PhotosPicker 16.0 Wrap PHPickerViewController
ShareLink 16.0 Wrap UIActivityViewController
presentationDetents(_:) 16.0 Full-height .sheet
scrollContentBackground(_:) 16.0 None
@Observable, @Bindable 17.0 ObservableObject + @Published
environment(_:) with an Observable object 17.0 environmentObject(_:)
SwiftData 17.0 Core Data or your own file storage
ContentUnavailableView 17.0 Build your own empty-state view
onChange(of:initial:_:) 17.0 onChange(of:perform:), deprecated from iOS 17
scrollPosition(id:anchor:) 17.0 ScrollViewReader
sensoryFeedback(_:trigger:) 17.0 UIImpactFeedbackGenerator
Tab (the new TabView syntax) 18.0 .tabItem

Before you write SwiftUI with AI, check your project’s minimum version: right-click the target in the KXBuild view, choose KXBuild: Edit Target Settings and read iOS Deployment Target on the General page. To change it, see Project settings.

FAQ

What do I do when AI-written SwiftUI code says “is only available in iOS 16.0 or newer”?

The code uses an API newer than your minimum iOS version. Send the AI the full error and your minimum version, and ask it to use an older API or wrap the code in if #available. Alternatively, raise the deployment target after checking your users’ versions. Pick one, not both.

Should I use @Observable or ObservableObject in SwiftUI?

New projects with a minimum of iOS 17 or later should use @Observable with @State, @Bindable and @Environment. Projects that support iOS 16 or earlier should use ObservableObject with @Published, @StateObject, @ObservedObject and @EnvironmentObject. Use only one approach per type.

How do I stop AI from using newer SwiftUI APIs?

State the minimum iOS version in your prompt or the repository’s instructions file, and name the APIs it must not use, such as NavigationStack, @Observable and SwiftData. Also require a build check after every change, so it can fix version errors by itself.

Can I check on Windows whether AI-generated SwiftUI code compiles?

Yes. Run kxbuild build .\MyApp.xcodeproj -D CODE_SIGNING_ALLOWED=NO in the project folder. Errors are printed as file:line:column: error: message, which you can hand straight to the AI. To see the UI and interactions, press F5 to install on your iPhone.

Does sample data in #Preview end up in the release build?

Sample data and mock services outside #if DEBUG are compiled into the release build. Put them inside #if DEBUG together with #Preview, don’t use a mock as the default value of a production parameter, and build once with the Release configuration to confirm.