●FUNDING — Rork closed a $15M seed round led by Left Lane Capital, with Peak XV, True Ventures, Goodwater, and a16z Speedrun●SCALE — In under a year, Rork became one of the world's largest AI mobile app building platforms by web traffic●MAX — Rork Max generates native Swift apps, powered by Claude Code and Claude Opus 4.6●NATIVE — Rork Max reaches Xcode-class territory: AR/LiDAR, Metal 3D, widgets, Live Activities, HealthKit, and Core ML●STACK — Standard Rork builds iOS and Android together in React Native (Expo), so non-engineers can ship real apps●PRICE — Plans start free, paid tiers from $25/month, and Rork Max at $200/month●FUNDING — Rork closed a $15M seed round led by Left Lane Capital, with Peak XV, True Ventures, Goodwater, and a16z Speedrun●SCALE — In under a year, Rork became one of the world's largest AI mobile app building platforms by web traffic●MAX — Rork Max generates native Swift apps, powered by Claude Code and Claude Opus 4.6●NATIVE — Rork Max reaches Xcode-class territory: AR/LiDAR, Metal 3D, widgets, Live Activities, HealthKit, and Core ML●STACK — Standard Rork builds iOS and Android together in React Native (Expo), so non-engineers can ship real apps●PRICE — Plans start free, paid tiers from $25/month, and Rork Max at $200/month
Migrating Rork Max SwiftUI to @Observable: Narrowing the Re-renders ObservableObject Was Spreading
Rork Max tends to generate SwiftUI apps built on ObservableObject and @Published, where a single state change re-evaluates every subscribing view. Moving to the Observation framework's @Observable narrows invalidation to the property level. Here is the migration path, plus the view-body execution counts I measured in Instruments before and after.
This article started with a faint hitch. I was scrubbing a SwiftUI list screen generated by Rork Max on a real device, and every time I lifted my finger at the end of a scroll, there was a half-beat of lag. Not a dropped frame — just a small delay I could feel but not name.
Attaching the Instruments SwiftUI template made it obvious. Tapping a single cell to toggle its favorite state re-evaluated the body of every card on screen at once. The cause was the shape of the ViewModel Rork Max had produced: the familiar pattern of an ObservableObject with a stack of @Published properties.
This article walks through moving that ViewModel to the Observation framework's @Observable, so invalidation narrows down to individual properties. I migrated a real project as an indie developer, and I want to leave a working log of what swaps cleanly and what breaks quietly.
Why ObservableObject widens re-renders
The mechanism behind ObservableObject is simple. When any @Published property changes, the object fires its objectWillChange publisher exactly once. A view that subscribes to that object through @ObservedObject or @StateObject cannot tell which property changed. It only receives the fact that "something changed," and it invalidates its own body.
So typing one character into a list filter marks every card sharing that ViewModel for re-evaluation. SwiftUI still diffs the result and minimizes the actual draw, but the body closures do run. If those closures build formatters or do heavy layout work, the cost stacks up once per invalidation.
Code generation like Rork Max tends to centralize state into a single ViewModel. Centralization reads well, but it amplifies the exact weakness of ObservableObject: the coarse granularity of objectWillChange.
What @Observable actually changes is tracking granularity
The Observation framework's @Observable macro changes that granularity at the root. When a view's body runs, only the properties it actually reads are recorded as that view's dependencies. If a card reads only wallpaper.isFavorite, it re-evaluates only when isFavorite changes. Change the filter string, and cards that never read that string stay untouched.
In other words, ObservableObject invalidates per object, while @Observable invalidates per property read. That difference matters precisely in screens like a list, where many views share one object.
Observation requires iOS 17 or later. Most apps Rork Max generates target recent iOS, so the prerequisite is usually met — just confirm your deployment target before you start.
✦
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
✦Why ObservableObject widens re-renders and how @Observable narrows them to the property level, shown as before/after code
✦View body execution counts measured in the Instruments SwiftUI template before and after migration (from ~3.6x baseline down to ~1.0x on a scrolling list)
✦The five places that break quietly during migration — @Bindable, optional @Environment, didSet — and how to route around each
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.
Here is the ObservableObject version Rork Max is likely to emit.
// Before: ObservableObjectimport SwiftUIimport Combinefinal class GalleryViewModel: ObservableObject { @Published var wallpapers: [Wallpaper] = [] @Published var searchText: String = "" @Published var isLoading: Bool = false var filtered: [Wallpaper] { guard !searchText.isEmpty else { return wallpapers } return wallpapers.filter { $0.title.localizedCaseInsensitiveContains(searchText) } } func toggleFavorite(_ id: Wallpaper.ID) { guard let index = wallpapers.firstIndex(where: { $0.id == id }) else { return } wallpapers[index].isFavorite.toggle() }}
Moving to @Observable takes surprisingly little. import Combine and the @Published markers disappear, and the class gains the @Observable macro.
// After: @Observableimport SwiftUIimport Observation@Observablefinal class GalleryViewModel { var wallpapers: [Wallpaper] = [] var searchText: String = "" var isLoading: Bool = false var filtered: [Wallpaper] { guard !searchText.isEmpty else { return wallpapers } return wallpapers.filter { $0.title.localizedCaseInsensitiveContains(searchText) } } func toggleFavorite(_ id: Wallpaper.ID) { guard let index = wallpapers.firstIndex(where: { $0.id == id }) else { return } wallpapers[index].isFavorite.toggle() }}
Dropping @Published can feel unsafe at first. But under @Observable, stored properties are tracked by default. You do not opt in by hand. The mental model inverts: you opt specific properties out with @ObservationIgnored instead.
The view-side mapping
Once the ViewModel moves, swap the view-side property wrappers. This is the bulk of the migration. The mapping is as follows.
ObservableObject era
After @Observable
Role
@StateObject var vm = VM()
@State var vm = VM()
View owns and creates it
@ObservedObject var vm: VM
var vm: VM (plain let/var)
Just passed from the parent
@EnvironmentObject var vm: VM
@Environment(VM.self) var vm
Injected from the environment
$vm.searchText (auto-bound)
$vm.searchText via @Bindable
Two-way binding
The owning view changes minimally — @StateObject becomes @State.
struct GalleryView: View { @State private var vm = GalleryViewModel() var body: some View { List(vm.filtered) { wallpaper in WallpaperCell(wallpaper: wallpaper) { vm.toggleFavorite(wallpaper.id) } } }}
Pass each cell only the model it needs, not the whole ViewModel. This is the crux of narrowing re-renders. If a cell reads a single property of Wallpaper, it re-evaluates only when that property changes.
struct WallpaperCell: View { let wallpaper: Wallpaper let onToggle: () -> Void var body: some View { HStack { Text(wallpaper.title) Spacer() Button(action: onToggle) { Image(systemName: wallpaper.isFavorite ? "heart.fill" : "heart") } } }}
Two-way binding needs @Bindable
For something like a search field where you want $ binding, you cannot write $vm.searchText directly against an @Observable object. You interpose @Bindable. Declare it at the property level for whole-view use, or rebind locally inside body with @Bindable var vm = vm.
struct SearchBar: View { @Bindable var vm: GalleryViewModel var body: some View { TextField("Search", text: $vm.searchText) .textFieldStyle(.roundedBorder) }}
Forget this step and $vm.searchText fails to compile. The error is clear, so it will not stump you — but it is the single most frequent stop along the way.
What Instruments measured, before and after
Feel is not a decision criterion, so I counted View Body executions in the Instruments SwiftUI template. The subject was a list of 60 cards, and the operation was "toggle one card's favorite" repeated 10 times.
Metric
ObservableObject
@Observable
Cell body executions per toggle
~60 (every visible card)
1 (the target card only)
Cumulative cell body over 10 toggles
~600
~10
List-wide body multiplier during scroll
~3.6x baseline
~1.0x baseline
The numbers shift with device and data volume, but the trend was unambiguous. Under ObservableObject, a single state change rippled to every card on screen; under @Observable, only the target card re-evaluates. The half-beat hitch at the end of a scroll stopped bothering me on-device as well.
The important part is that this improvement did not come from making heavy work faster. The work itself is unchanged. What changed is the scope — who gets re-rendered. It is a subtractive optimization, which is also why its side effects are easy to reason about.
Five places that break quietly
The migration is mostly clean, but a few spots change behavior without the compiler warning you. Here they are, roughly in the order I tripped over them.
Spot
What happens
Workaround
Optional environment injection
@Environment(VM.self) var vm crashes at runtime if never injected
To keep it optional, receive it as @Environment(VM.self) private var vm: VM?
didSet / Combine reliance
Properties still work, but the $prop Combine pipelines are gone
Move side effects into didSet or a method; drop the Combine assumption
Computed property tracking
Derived values like filtered are tracked correctly via the stored props they read (easy to misjudge)
No marker needed on derived values; design around the read source being tracked
Non-view observers
Logic that leaned on objectWillChange stops firing
Observe explicitly with withObservationTracking
Constants you want immutable
All stored props are tracked by default, inviting unintended re-evaluation
Mark unchanging values with @ObservationIgnored
Combine pipelines that depend on $prop won't compile, so you'll catch those. But a small utility subscribed to objectWillChange goes silent without a word. Grep the whole project for objectWillChange before migrating, and you'll sleep easier.
Getting Rork Max to emit @Observable from the start
Alongside migrating existing code, you want new code generated as @Observable from the outset. Rork Max follows prompt instructions faithfully, so stating the state-management policy explicitly pays off. I attach a line like this.
For state management, do not use ObservableObject or @Published.Implement it with the Observation framework's @Observable macro.Use @State and @Environment for view property wrappers,and route two-way bindings through @Bindable.
With that instruction in place, the generated ViewModel arrives as @Observable, and the follow-up rewrite nearly vanishes. Changing the generation policy tends to cost fewer keystrokes over time than fixing generated code after the fact.
Wrapping up
Migrating from ObservableObject to @Observable is not a flashy feature. But it quietly tidies up something you feel directly: how far a single change ripples across the screen.
The sequence is straightforward. Strip @Published and import Combine from the ViewModel and add @Observable; swap the view-side property wrappers per the mapping table; interpose @Bindable only where you need two-way binding. Then measure body executions in Instruments, and sweep away any leftover objectWillChange dependencies. Follow that order and the move should carry no major surprises.
Next time you feel a faint hitch at the end of a list scroll, look at which views are re-evaluating in Instruments before you suspect the frame rate. Narrowing the scope solves it more often than you'd expect. I hope this helps with your own implementation, and 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.