Let me start with an honest admission. Rork Max generates SwiftUI fast — really fast. You write a prompt, and a few seconds later, a working app skeleton is sitting on your screen. I was floored the first time I tried it. Then I uploaded my first one to App Store Connect, and Apple sent it back. The reasons were a crash on iOS 16, a memory leak in a list view, and an accessibility violation on a heart-shaped favorite button. None of those were obvious in the simulator, and none of them showed up in the design preview — they only surfaced when a reviewer's phone tried to run my code in the wild.
Thirty-plus shipped Rork Max apps later, I have a fixed checklist that runs on every generation before it touches a real device. This article walks through the ten patterns on that checklist, each with the actual before/after code that fixed it for me. By the time you finish reading, the "works on my simulator but I'm not sure" code on your laptop should turn into "works, and I can submit it tonight" code. I'll also share the prompt template I now paste at the top of every Rork Max project, which has dropped my post-generation refactoring time to roughly a third of what it used to be.
Why Generated Code Doesn't Ship As-Is
Rork Max's AI is tuned to produce running code in the shortest amount of time. That tradeoff is exactly what you want for the first 80% of an app — you can have a working prototype on a real device an hour after the idea hits you, which is genuinely magical when you remember how long this used to take. But it also means the final 20% of work, the part that turns a prototype into a shippable product, is left to you. Specifically, three categories of issue show up almost every time:
- Edge case handling — empty arrays, network failures, cancellation, slow connections, expired tokens
- iOS version branching — generated code uses the latest API and crashes on older OS versions, and the simulator doesn't always show the issue
- Accessibility and localization — VoiceOver labels, RTL languages, dynamic type scaling, dark mode contrast
You can chase some of this with better prompts. I've tried, and you can probably squeeze a little more out of it. But you can never close the gap entirely with prompting alone, because the AI doesn't know what your future users will do, what devices they'll have, or which locale they live in. Manual review remains the last line of defense, and treating it that way — as a deliberate, repeatable process rather than an afterthought — has been what separates my apps that ship in one review cycle from the ones that bounce three times. If you want to push more of the work to the prompt side, my earlier post on Rork Max SwiftUI: AI prompt design and generation quality covers that. This article picks up where that one ends — what to do with the code Rork Max actually hands you.
A note on terminology before we go: when I say "generated code" I mean the SwiftUI Swift files that Rork Max emits. The patterns below are independent of which AI model is currently powering Rork Max under the hood — I've seen the same anti-patterns surface across every iteration of the generator I've used.
Pattern 1: Confusing @StateObject and @ObservedObject
This is the bug I see most often. Rork Max sometimes uses @ObservedObject on the parent view, which means the ViewModel gets recreated every time SwiftUI redraws the parent. The generated code looks correct because the data flow appears right at first glance, and the bug only manifests as flicker, double network requests, or "why does this state keep resetting" once the app is in real use.
// ❌ Before — A pattern Rork Max produces frequently
struct TaskListView: View {
@ObservedObject var viewModel = TaskViewModel() // Recreated on each redraw
var body: some View {
List(viewModel.tasks) { task in
TaskRow(task: task)
}
}
}It looks fine on first launch. But every time the parent re-renders — and SwiftUI re-renders far more aggressively than UIKit ever did — you get a fresh TaskViewModel(), which fires another network request and resets @Published properties. If your ViewModel has a long-running loadInitialData() in its initializer, congratulations, you just made it run six times by accident.
// ✅ After — Lifecycle is explicit
struct TaskListView: View {
@StateObject private var viewModel = TaskViewModel() // Tied to the view's lifetime
var body: some View {
List(viewModel.tasks) { task in
TaskRow(task: task)
}
}
}
struct TaskRow: View {
@ObservedObject var task: Task // Passed in from the parent — @ObservedObject is correct here
var body: some View {
HStack {
Text(task.title)
Spacer()
if task.isCompleted { Image(systemName: "checkmark") }
}
}
}The rule is simple: if the view owns the ViewModel, use @StateObject. If it's passed in from outside, use @ObservedObject. Walk every ViewModel property in your generated code through that test, and roughly 80% of "why is my screen flashing" bugs disappear. If you find yourself wanting @StateObject on a property that's passed in, it's a sign that the parent should own the ViewModel and pass it down — not that you should make the child create one. This is a place where the SwiftUI mental model genuinely differs from UIKit, and it's worth taking ten minutes to internalize before you ship.
Pattern 2: Tasks That Never Get Cancelled
A surprising amount of generated code wraps async work in Task { ... } inside .onAppear instead of using .task. The result: the work keeps running after the view disappears, which wastes bandwidth, drains battery, and occasionally overwrites fresh data with stale data.
// ❌ Before — Async work that never cancels
struct FeedView: View {
@State private var posts: [Post] = []
var body: some View {
List(posts) { Text($0.title) }
.onAppear {
Task {
posts = try await API.fetchPosts() // Keeps running after the view is gone
}
}
}
}When the user navigates away and comes back, an old in-flight response can suddenly overwrite what's on screen. I once had an "items disappearing on tap" bug that turned out to be exactly this — an outbound request from a previous session was finishing late and overwriting a freshly mutated array. It took me a full day to diagnose because I was looking for the bug in the tap handler, not in a phantom request fired three navigations ago.
// ✅ After — .task auto-cancels when the view goes away
struct FeedView: View {
@State private var posts: [Post] = []
@State private var loadError: Error?
var body: some View {
List(posts) { Text($0.title) }
.task {
do {
posts = try await API.fetchPosts()
} catch is CancellationError {
return // Swallow cancels silently
} catch {
loadError = error
}
}
.alert("Failed to load", isPresented: .constant(loadError != nil)) {
Button("OK") { loadError = nil }
}
}
}.task is tied to the SwiftUI view lifecycle and cancels automatically when the view is removed from the hierarchy. Make sure you also catch CancellationError quietly — otherwise tapping the back button shows users an error alert about an operation that was supposed to be cancelled, which is a confusing experience. Treat cancellation as the expected, normal exit path that it is.
Pattern 3: Lists That Don't Recycle Cells
Rork Max occasionally generates ScrollView { LazyVStack } instead of List. That's fine for short content but performs poorly when the list grows.
// ❌ Before — Memory keeps climbing
ScrollView {
LazyVStack {
ForEach(items) { item in
ComplexRow(item: item) // Every row that scrolls into view stays allocated
}
}
}In a real test of mine, a 1,000-row list ate close to 200MB of memory and crashed on a low-end device. The reason is subtle: LazyVStack lazily creates rows but doesn't actively destroy them when they scroll off-screen, especially if they capture state via @State or are wrapped in a parent that holds references. List cell recycling is a real, measurable advantage on long content.
// ✅ After — List recycles cells
List(items) { item in
ComplexRow(item: item)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.listStyle(.plain)The "List is hard to customize" idea is a holdover from iOS 14. Since iOS 17, listRowSeparator, listRowInsets, and listSectionSpacing cover most of what you need, and the memory profile is dramatically better. The one exception where LazyVStack makes sense is when you genuinely need a non-tabular layout — chat bubbles aligned to one side, masonry grids, etc. For everything else that looks like a list, List is the right call.
Pattern 4: Identifiable Violations That Break Rendering
Generated code often passes id: \.self to ForEach. The moment two elements have the same value, SwiftUI starts rendering the wrong rows.
// ❌ Before — Two identical comments break the UI
ForEach(comments, id: \.text) { comment in
CommentRow(text: comment.text)
}A user posts "thanks!" twice, and suddenly one of them is invisible — or deleting one removes the other. Pure chaos. This bug is especially painful because it usually doesn't show up until after launch, when real users start posting things you didn't anticipate.
// ✅ After — Use a real identifier
struct Comment: Identifiable {
let id: UUID // or String from the server
let text: String
let author: String
let createdAt: Date
}
ForEach(comments) { comment in // Identifiable means no explicit id needed
CommentRow(comment: comment)
}Always use a real, server-issued ID when possible. If you generate IDs locally, do it in the initializer — not in a var id: UUID { UUID() } computed property, which produces a new value on every access and defeats the whole point of identity. The cleanest pattern I've found is to require id in the initializer of the model and let Swift's typed initializer error keep me honest.
Pattern 5: Updating UI Off the Main Thread
If your team is new to Swift Concurrency, generated code can ship without @MainActor, which produces "Publishing changes from background threads" warnings and the occasional crash.
// ❌ Before — Xcode warning + intermittent crash
class FeedViewModel: ObservableObject {
@Published var posts: [Post] = []
func load() async {
let result = try? await API.fetchPosts()
self.posts = result ?? [] // Background thread updating @Published
}
}The crash is intermittent on the simulator and reliable on real devices under memory pressure, which is a uniquely bad debugging experience: it works on your machine and breaks on the reviewer's. Catching this in code review saves a real-world rejection.
// ✅ After — Class-level @MainActor
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var posts: [Post] = []
@Published private(set) var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
posts = try await API.fetchPosts()
} catch {
// Surface error via another @Published property
}
}
}Adding @MainActor at the class level pins all methods to the main thread. If you have CPU-heavy work that must run off-main, mark only that method nonisolated func processInBackground() async. This pattern handles 99% of production cases cleanly, and the times it doesn't tend to be when you're doing image processing or video encoding — at which point you genuinely should be off-main and nonisolated makes that explicit.
Pattern 6: Untracked Navigation Paths
When Rork Max uses NavigationStack, it sometimes omits the path binding entirely.
// ❌ Before — No way to programmatically pop
NavigationStack {
HomeView()
}You'll regret this the moment a designer asks for "after checkout success, return all the way to the home screen." There is no popToRoot API in SwiftUI without a path binding, and retrofitting one across an existing app is a tedious refactor that touches every screen.
// ✅ After — Hold the path so you can drive navigation
@MainActor
final class AppRouter: ObservableObject {
@Published var homePath = NavigationPath()
func popToHomeRoot() { homePath.removeLast(homePath.count) }
}
struct ContentView: View {
@StateObject private var router = AppRouter()
var body: some View {
NavigationStack(path: $router.homePath) {
HomeView()
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
.navigationDestination(for: CheckoutStep.self) { step in
CheckoutView(step: step)
}
}
.environmentObject(router)
}
}Now the checkout success screen calls router.popToHomeRoot() and SwiftUI snaps back to the root. The same path also makes deep links and push-notification handlers easy to write — both essentially become "compose a NavigationPath and assign it to router.homePath," which is a much easier mental model than chasing imperative pushViewController calls. For more on that piece, Rork Max deep linking and universal links goes deeper.
Pattern 7: AsyncImage Without a Cache
AsyncImage works for prototypes but turns into a memory and bandwidth problem in real lists.
// ❌ Before — Refetches the same URL every scroll
List(items) { item in
AsyncImage(url: URL(string: item.imageURL))
.frame(width: 80, height: 80)
}Every time a row scrolls back into view, the image is downloaded again. On a slow connection it shows the placeholder; on a metered connection it costs the user money. On a feed-heavy app, this can be hundreds of megabytes of redundant bandwidth per session.
// ✅ After — Use a real cache layer
import Kingfisher
List(items) { item in
KFImage(URL(string: item.imageURL))
.placeholder { Color.gray.opacity(0.2) }
.fade(duration: 0.25)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
}You can roll your own cache on top of URLSession and URLCache, but for production work I would rather pay the dependency cost. Kingfisher and Nuke are both well-maintained and have minimal binary footprint — I happen to use Kingfisher because it has an SDK API I find easier to read, and its memory cache eviction policy has been more predictable for me under stress. Either choice is far better than reinventing the wheel.
Pattern 8: Hard-Coded Strings Hiding Localization Debt
Rork Max generates labels in whichever language your prompt was written in. Convenient, but the result isn't localizable code.
// ❌ Before — Hard-coded English (or Japanese, etc.)
Button("Subscribe") { subscribe() }
Text("Unlock all features for $9.99/month")Decide to launch in another country a year later, and you'll spend a weekend converting strings one screen at a time, hunting through every Text(...) and every Button(...) to track down the hard-coded literal.
// ✅ After — Route everything through Localizable.xcstrings
Button(String(localized: "subscribe.button.title")) { subscribe() }
Text(String(localized: "subscribe.price.monthly"))String(localized:) shipped in iOS 15. Combined with the .xcstrings format introduced in Xcode 15, it's a genuinely better workflow than the legacy .strings files — missing keys are surfaced in the catalog UI and pluralization is built in. Wire this up on day one even if you only have one locale, because retrofitting later is expensive and tedious. The cost of doing it upfront is essentially zero; the cost of doing it later is measured in days.
Pattern 9: UI That VoiceOver Can't Read
Accessibility gaps are sometimes flagged in App Store review, and they're always a moral problem. Rork Max often skips accessibilityLabel, so I treat this as a mandatory pre-submit check.
// ❌ Before — VoiceOver only says "button"
Button(action: addToFavorites) {
Image(systemName: "heart")
}
// ❌ Before — Decorative image gets read aloud as "image"
Image("background-pattern")// ✅ After — Real labels and traits
Button(action: addToFavorites) {
Image(systemName: "heart")
}
.accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites")
.accessibilityHint("Double-tap to toggle")
Image("background-pattern")
.accessibilityHidden(true) // Decorative image — skip read-aloudMy personal rule: open Accessibility Inspector, run "Audit" on every screen, and don't submit until it shows zero warnings. Skipping this is how you ship an app that visually impaired users can't use at all, which I would not want to defend in any context. Accessibility audits also tend to surface other UI issues — contrast ratios, hit-area sizes — that improve the experience for everyone, not just users relying on assistive technology.
Pattern 10: API Keys Baked Into the Binary
This one isn't strictly a SwiftUI issue, but it bites Rork Max users a lot. When you include a key in your prompt, the AI sometimes embeds it in a Swift file directly.
// ❌ Before — Leaked the moment you push to GitHub
struct API {
static let openAIKey = "YOUR_API_KEY_PLACEHOLDER"
static let stripeKey = "YOUR_STRIPE_PUBLISHABLE_KEY"
}GitHub Secret Scanning will catch most of these, but if it slips through to a public repo, attackers find the keys within minutes. A friend of mine accidentally pushed an OpenAI key once, and woke up to an $800 bill. Even on private repos, baking keys into a shipped binary is a problem because anyone who unzips your IPA can recover them.
// ✅ After — Info.plist + xcconfig per environment
// API.swift
struct API {
static var openAIKey: String {
Bundle.main.object(forInfoDictionaryKey: "OPENAI_API_KEY") as? String ?? ""
}
}
// Debug.xcconfig (gitignored)
OPENAI_API_KEY = sk-debug-xxxxxxxxxxxx
// Release.xcconfig (gitignored)
OPENAI_API_KEY = sk-prod-xxxxxxxxxxxx
// Info.plist
// <key>OPENAI_API_KEY</key>
// <string>$(OPENAI_API_KEY)</string>Better still: don't call third-party APIs directly from the client at all. Put a tiny backend in front of them so the keys never touch the device. Cloudflare Workers gives you enough free quota for most consumer apps, and the deployment story is tiny. You also get rate limiting, request logging, and the ability to rotate keys without shipping a new app version. If you want a worked example, Rork Max with Cloudflare Workers + tRPC type-safe backend goes through it.
Three More Mistakes That Aren't Quite Their Own Pattern
Beyond the ten above, there are a few smaller issues that show up frequently enough to flag without giving each its own section.
Force-unwrapping URLs. Generated code often writes URL(string: someString)! because the compiler is happy and the test data is well-formed. The first time a user pastes a typo into a "share via URL" sheet, your app crashes. Use guard let url = URL(string: someString) else { return } and surface a friendly error if you must — never trust input enough to force-unwrap it.
Hard-coded Color.black and Color.white in shared components. This breaks dark mode the moment a user toggles it. Use Color.primary, Color.secondary, and the named asset catalog colors. The asset catalog approach is essential when you need different colors for light and dark modes, and Rork Max sometimes skips the catalog entry even when it generates a Color reference.
Missing @FocusState for text-field-heavy screens. A login screen without keyboard focus management makes users tap each field manually. Adding @FocusState and .focused($field, equals: .email) modifiers takes ten minutes and noticeably improves the form-filling experience. Reviewers and TestFlight users mention this regularly when it's missing.
These three aren't refactoring patterns in the strict sense — they're more like polish items — but they consistently catch reviewers' attention and they're cheap to fix in a final pass.
A Worked Example: One File, All Ten Patterns Applied
To make the cumulative effect concrete, here's a small generated screen that I actually shipped, before and after the full ten-pattern audit.
// Before: Rork Max output, copied as-is
struct PaymentScreen: View {
@ObservedObject var vm = PaymentVM()
var body: some View {
ScrollView {
LazyVStack {
ForEach(vm.options, id: \.title) { o in
Button(action: { vm.choose(o) }) {
HStack {
Image(systemName: o.icon)
Text(o.title)
}
}
}
}
}
.onAppear {
Task { await vm.load() }
}
}
}After applying patterns 1, 2, 3, 4, 5, 8, and 9, the same screen looks like this:
@MainActor
final class PaymentVM: ObservableObject {
@Published private(set) var options: [PaymentOption] = []
func load() async { /* ... */ }
func choose(_ option: PaymentOption) { /* ... */ }
}
struct PaymentScreen: View {
@StateObject private var vm = PaymentVM()
var body: some View {
List(vm.options) { option in
Button {
vm.choose(option)
} label: {
HStack {
Image(systemName: option.icon)
.accessibilityHidden(true)
Text(String(localized: option.titleKey))
}
}
.accessibilityLabel(String(localized: option.titleKey))
}
.listStyle(.plain)
.task { await vm.load() }
}
}The diff is small in lines but huge in correctness: the ViewModel now lives at the right scope, the work cancels when the screen disappears, the list recycles properly, the items are properly identifiable, the strings are localizable, and VoiceOver reads each option clearly. This is the level of difference the ten-pattern audit produces consistently.
What to Run After the Refactor
Once the ten patterns are clean, three more steps before you submit:
Run Xcode's Static Analyzer via Product → Analyze. Concurrency warnings here turn into a flood of crash reports in Crashlytics if you ignore them, and the analyzer catches a lot more than just concurrency — uninitialized properties, dead code, retain cycles. Run Instruments — Allocations and Leaks for at least five minutes of real interaction. Watch for memory that climbs and never drops, especially after you navigate to a screen and back. A flat or saw-toothed memory graph is the goal. Finally, push to TestFlight internal testing and use the build for at least 30 minutes on a low-end device — an iPhone SE 3rd-gen is a great stress test. Heat and battery problems hide in the simulator and show up here, where the CPU has thermal limits and the battery is finite.
If you can, also have a friend who isn't an iOS developer use the app for ten minutes. They'll find UX issues that you've trained yourself to ignore — a hit area that's slightly too small, a loading spinner that runs too long, a back button placement that doesn't match user expectations. This step is worth more than another round of Instruments profiling.
Feed Your Fixes Back into Your Prompts
This is the part most teams miss. Once you know the patterns Rork Max gets wrong, write them into the prompt template you use for every project. The next generation comes out closer to production, and over time the gap between "generated" and "shippable" closes — not because the AI improves on its own, but because you've encoded your knowledge into the prompt.
This is what I paste at the top of every Rork Max project now:
## Code Quality Requirements
- Own ViewModels with @StateObject; pass them down with @ObservedObject
- Use .task for async work; catch CancellationError silently
- Annotate ObservableObject classes with @MainActor
- Only use ForEach with Identifiable types; never use id: \.self
- Provide accessibilityLabel for every Button and Image
- Wrap user-facing strings in String(localized:)
- Read API keys from Info.plist via xcconfig, never hard-code them
- Prefer List over LazyVStack for tabular content
- Use a path-bound NavigationStack via an AppRouter ObservableObject
- Use a real image cache (Kingfisher or Nuke) instead of bare AsyncImage
Since I started using this, the time I spend on post-generation refactoring has dropped to roughly a third of what it used to be. To me, that's what "AI-assisted development" actually means in practice — combining generation with a tight review loop where the human encodes their lessons. The AI does the typing; you do the judging.
Where to Go Next
Open whatever Rork Max generation is on your screen right now and audit it against just one of these patterns. In my experience, almost every project has either Pattern 1 (@StateObject mix-up) or Pattern 5 (missing @MainActor). Fixing one is enough to feel a stability difference. Then keep working through the rest, and you'll have something you can submit to App Store with confidence — which, in turn, frees you up to spend your time on the parts of the product where AI generation can't help: the marketing, the pricing, the user research, the brand. Those are the parts where shipping faster turns into real outcomes.