●MAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode required●STACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decision●FOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generation●BUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebase●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)●PRICING — It is free to start, with paid plans from $25/month, so you can try before committing●MAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode required●STACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decision●FOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generation●BUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebase●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)●PRICING — It is free to start, with paid plans from $25/month, so you can try before committing
Adding SwiftData to a SwiftUI App Generated by Rork Max
Rork Max can produce polished SwiftUI screens, but persistence often stops at @State. Here is how I layer SwiftData onto generated code: model design, wiring the container to views, and a schema migration pattern that survives shipping.
One evening I was looking at the SwiftUI code Rork Max gave me after I asked it to "build a habit tracker," and my hand froze on the trackpad. The screens were more thoughtful than I expected — the list, the checkboxes, all neatly laid out. But every time I relaunched the app, every habit I had entered was gone.
The reason was obvious the moment I opened the source. The data lived only in @State private var habits: [Habit] = [], never written to disk at all. Rork Max is excellent at producing a screen that looks like it works as fast as possible, but it does not design the persistence layer for you. That was the lesson of the night.
In this article I want to record the full path of taking that generated code and growing it into an app that survives a relaunch, with the actual code I wrote. I am assuming iOS 17 or later and that you can open the SwiftUI Rork Max emits in Xcode or a cloud build.
Why staying on @State eventually breaks
Freshly generated code usually looks like this.
struct Habit: Identifiable { let id = UUID() var name: String var doneToday: Bool}struct ContentView: View { @State private var habits: [Habit] = [ Habit(name: "Morning walk", doneToday: false), Habit(name: "Reading", doneToday: false) ] var body: some View { List($habits) { $habit in Toggle(habit.name, isOn: $habit.doneToday) } }}
As a screen, this is finished. But @State is in-memory state tied to the lifetime of the view, so it disappears when the process ends. Good enough for a demo, not viable for an app people open every day.
Many of us reach for serializing a Codable into UserDefaults as JSON next. I did exactly that in my early apps. The trouble is that once items grow and you need relationships or query conditions, reading and writing the whole JSON blob gets painful fast. SwiftData is designed for data that grows, so as an indie developer I have found that spending a little effort up front to move onto it pays off later.
Step 1: Replace the struct with @Model
The first move is turning the struct into an @Model class. The catch is that it becomes a final class rather than a struct — miss that and it will not compile.
import SwiftData@Modelfinal class Habit { var name: String var doneToday: Bool var createdAt: Date init(name: String, doneToday: Bool = false, createdAt: Date = .now) { self.name = name self.doneToday = doneToday self.createdAt = createdAt }}
Once @Model is applied, SwiftData tracks property changes automatically and treats the object as something to persist. You do not need to hold an id by hand; SwiftData manages an internal persistent identifier.
There is a judgment call here. If the generated code still has let id = UUID(), you can drop it as long as your views and animations do not depend on it. But if a ForEach references it via id:, I prefer to keep an explicit UUID property as a stable identifier. Rork Max output tends to be ambiguous here, so it is safest to check how ForEach is used before deciding.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A step-by-step path from @State-only generated code to SwiftData @Model storage
✦VersionedSchema and MigrationPlan code that lets your schema grow without breaking the app
✦A ModelContainer fallback design that keeps the app launching even when migration fails
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
With the model defined, hand a ModelContainer to your app's entry point. Think of it as adding one line, .modelContainer(for:), to the App struct Rork Max generated.
import SwiftUIimport SwiftData@mainstruct HabitApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: Habit.self) }}
Then in ContentView, replace the @State array with @Query. This is the single most effective line in the whole article.
struct ContentView: View { @Environment(\.modelContext) private var context @Query(sort: \Habit.createdAt) private var habits: [Habit] var body: some View { List { ForEach(habits) { habit in Toggle(habit.name, isOn: Bindable(habit).doneToday) } .onDelete { offsets in for index in offsets { context.delete(habits[index]) } } } .toolbar { Button("Add") { context.insert(Habit(name: "New habit")) } } }}
@Query streams the database contents straight into the view and re-renders automatically when anything changes. Because calling context.insert or context.delete triggers a save, you do not have to write an explicit save() each time. Using Bindable(habit) for the Toggle binding is the standard way to edit an @Model object.
At this point you have an app whose data survives a relaunch. But the real difficulty is still ahead: the day you want to change the model after shipping.
Step 3: Design schema migration from the start
When you add a property to a shipped model, existing users still have data in the old schema on their devices. With no plan, SwiftData can detect the schema mismatch at launch and crash. I once fell into this by adding createdAt later as non-optional. My test device was a fresh install so I never noticed — only existing users who received the update crashed right at launch. The worst possible pattern.
VersionedSchema and SchemaMigrationPlan prevent this. You freeze the model's shape per version and make the migration path explicit.
import SwiftDataenum HabitSchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0) static var models: [any PersistentModel.Type] { [Habit.self] } @Model final class Habit { var name: String var doneToday: Bool init(name: String, doneToday: Bool = false) { self.name = name self.doneToday = doneToday } }}enum HabitSchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) static var models: [any PersistentModel.Type] { [Habit.self] } @Model final class Habit { var name: String var doneToday: Bool var createdAt: Date = Date.now // new properties must carry a default value init(name: String, doneToday: Bool = false, createdAt: Date = .now) { self.name = name self.doneToday = doneToday self.createdAt = createdAt } }}enum HabitMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [HabitSchemaV1.self, HabitSchemaV2.self] } static var stages: [MigrationStage] { [.lightweight(fromVersion: HabitSchemaV1.self, toVersion: HabitSchemaV2.self)] }}
Two things matter. Always give a new property a default value. And if the change is a simple addition that a default value can fill, a .lightweight stage is enough. If you need to rename fields or transform data, you write a .custom stage with willMigrate / didMigrate closures, but designing changes so they can be absorbed by lightweight migration makes operations dramatically easier.
Pass the migration plan when initializing the ModelContainer.
let container = try ModelContainer( for: HabitSchemaV2.Habit.self, migrationPlan: HabitMigrationPlan.self)
If you cut V1 from the very first release, the second change will not catch you off guard. These days I wrap even the tiniest app in a VersionedSchema from day one, because retroactively defining "the original schema" later is far more tedious than it sounds.
Step 4: A fallback that never blocks launch
Even with migration in place, the chance of ModelContainer creation failing — disk corruption, an unexpected schema mismatch — is never exactly zero. Initialize with try! and the app will not even launch at that moment. Generated code often uses try!, so this is a spot worth fixing by hand every time.
What I actually ship is a two-tier setup that falls back to an in-memory container on failure.
@mainstruct HabitApp: App { let container: ModelContainer init() { do { container = try ModelContainer( for: HabitSchemaV2.Habit.self, migrationPlan: HabitMigrationPlan.self ) } catch { // Let the app launch even if persistence fails let fallback = ModelConfiguration(isStoredInMemoryOnly: true) container = try! ModelContainer(for: HabitSchemaV2.Habit.self, configurations: fallback) // Logging this to your crash reporter makes root-causing much faster } } var body: some Scene { WindowGroup { ContentView() } .modelContainer(container) }}
Falling back to memory means data is not saved, but it at least avoids the worst experience of "crashing the instant it launches." Send the failure to your crash reporting platform and you can later trace which device and which version the migration tripped on. From the user's side it reads as "history temporarily disappeared," which I consider far better than "the app never opens again."
The order to follow when working with generated code
Looking back over these steps, the flow is to receive the screen code Rork Max produced as a "working foundation," then apply your own hands in order: persistence, wiring, migration, and fallback. What AI generates is a fast starting point; the judgment needed to turn it into an app people touch every day still rests with the maker. That is how I frame it today.
For your next move, pick just one @State in the app you are building right now, swap it to @Model, and check whether data survives a relaunch. That small step is the first switch that moves generated code from "demo" toward "product."
I hope this helps anyone else growing Rork Max output the same way. Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.