Swiftui observable viewmodel boilerplate
Skill j-morgan6/iOS-from-web-guide/skills/swiftui-observable-viewmodel-boilerplate
MANDATORY for creating any ViewModel. Use before writing any file under ViewModels/ or any class whose name ends in ViewModel.From its SKILL.md
npx -y skills add j-morgan6/iOS-from-web-guide --skill swiftui-observable-viewmodel-boilerplateAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
5.7 KB, as published. Nobody here has run it
SwiftUI @Observable ViewModel Boilerplate
RULES — Follow these with no exceptions
- Use
@Observable(Swift Observation, iOS 17+). Never@ObservableObject/@Published/@StateObject/@ObservedObject. This rule is enforced by thepublished-on-ios17hook. - Every ViewModel is
@MainActor @Observable final class.@MainActorbecause views read it on the main thread;finalbecause subclassing an Observable breaks tracking. - Views hold ViewModels via
@State, not@StateObjector@ObservedObject.@Stateis the correct property wrapper for@Observablereference types since iOS 17. - One ViewModel per screen. A view doesn't share a ViewModel with its parent — if it needs parent data, pass values in the initializer.
- Public mutable properties; no
@Published. The macro tracks every stored property automatically. Add explicit@ObservationIgnoredonly on caches or other internal state that shouldn't trigger re-renders. - Async methods do the network work. The View calls
.task { await viewModel.load() }, notonAppear { viewModel.load() }. - Loading / data / error state is explicit. Three state properties:
isLoading: Bool, the data (items: [T]), anderrorMessage: String?. No nil-as-sentinel loading states.
When to invoke
- Creating a new file under
ViewModels/. - Converting a pre-iOS-17
@ObservableObjectViewModel to@Observable. - Debugging "view doesn't re-render when I mutate the ViewModel" bugs.
The canonical shape
import SwiftUI
@MainActor
@Observable
final class FeedViewModel {
var posts: [Post] = []
var isLoading = false
var errorMessage: String?
func load() async {
isLoading = true
errorMessage = nil
do {
let response: PaginatedResponse<Post> = try await APIClient.shared.get(path: "/feed")
posts = response.data
} catch {
errorMessage = "Couldn't load feed. Pull to refresh."
}
isLoading = false
}
func toggleLike(_ post: Post) {
// Optimistic mutation — see swiftui-optimistic-ui-pattern
guard let idx = posts.firstIndex(where: { $0.id == post.id }) else { return }
posts[idx].likedByCurrentUser.toggle()
Task {
_ = try? await APIClient.shared.post(path: "/posts/\(post.id)/like") as EmptyResponse
}
}
}
Testability note: when the ViewModel will be unit-tested, inject the API client behind a protocol instead of calling the singleton directly — init(api: APIClientProtocol = APIClient.shared). See ios-feature-scaffold and the templates/APIClientProtocol.swift / templates/MockAPIClient.swift templates.
The canonical View shape
struct FeedView: View {
@State private var viewModel = FeedViewModel()
var body: some View {
List(viewModel.posts) { post in
FeedCardView(post: post)
}
.overlay {
if viewModel.isLoading && viewModel.posts.isEmpty {
ProgressView()
}
}
.task {
await viewModel.load()
}
}
}
Why @State for an @Observable class
Before iOS 17, classes used @StateObject because @State required Equatable value semantics. The Observation framework removed that requirement: @State now correctly owns the reference, participates in SwiftUI's identity system, and re-renders on any tracked property change.
Using @StateObject with @Observable compiles but is wrong — you get a warning and observation won't trigger.
Common pitfalls
Mixing @Published and @Observable
Anti-pattern (flagged by the published-on-ios17 hook):
// ❌ NEVER
@Observable
final class BadViewModel {
@Published var items: [Item] = [] // @Published has no effect here
}
Fix: Remove @Published. The @Observable macro auto-tracks every stored property.
Using @ObservedObject in the View
// ❌
struct FeedView: View {
@ObservedObject var viewModel: FeedViewModel // doesn't own the lifetime
}
Fix: @State private var viewModel = FeedViewModel() if the view owns it, or plain let viewModel: FeedViewModel if the parent injects it.
Not annotating @MainActor
Symptom: "Property access must be on main actor" crash when a network callback mutates posts.
Fix: @MainActor on the class declaration. All methods now run on main; network work happens inside async methods and the await hops off and back on.
onAppear instead of .task
// ❌
.onAppear { Task { await viewModel.load() } }
Fix: .task { await viewModel.load() }. .task is cancelled when the view disappears; onAppear leaks.
Observation silently broken
If re-renders don't happen:
- Check the class has
@Observable— not@ObservableObject. - Check the view uses
@State— not@StateObject. - Check the property is stored (not computed). Observation doesn't track computed properties unless they read other tracked properties during evaluation.
- Check
deploymentTarget.iOSis 17+ inproject.yml.
Template reference
There's no dedicated template — @Observable ViewModels are pure boilerplate. See ios-feature-scaffold for the generator that produces Model + ViewModel + View in one pass.
Related skills
swiftui-optimistic-ui-pattern— for mutation methods.swiftui-equatable-hashable-for-diffing— so your model types trigger re-renders correctly.ios-api-client-foundation— for the networking layer called fromasyncmethods.