From ios-from-web-guide
MANDATORY for creating any ViewModel. Invoke before writing any file under ViewModels/ or any class whose name ends in ViewModel.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ios-from-web-guide:swiftui-observable-viewmodel-boilerplateThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **Use `@Observable` (Swift Observation, iOS 17+).** Never `@ObservableObject` / `@Published` / `@StateObject` / `@ObservedObject`. This rule is enforced by hook H-W-6.
@Observable ViewModel Boilerplate@Observable (Swift Observation, iOS 17+). Never @ObservableObject / @Published / @StateObject / @ObservedObject. This rule is enforced by hook H-W-6.@MainActor @Observable final class. @MainActor because views read it on the main thread; final because subclassing an Observable breaks tracking.@State, not @StateObject or @ObservedObject. @State is the correct property wrapper for @Observable reference types since iOS 17.@Published. The macro tracks every stored property automatically. Add explicit @ObservationIgnored only on caches or other internal state that shouldn't trigger re-renders..task { await viewModel.load() }, not onAppear { viewModel.load() }.isLoading: Bool, the data (items: [T]), and errorMessage: String?. No nil-as-sentinel loading states.ViewModels/.@ObservableObject ViewModel to @Observable.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
}
}
}
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()
}
}
}
@State for an @Observable classBefore 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.
@Published and @ObservableAnti-pattern (enforced by hook H-W-6):
// ❌ 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.
@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.
@MainActorSymptom: "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.
If re-renders don't happen:
@Observable — not @ObservableObject.@State — not @StateObject.deploymentTarget.iOS is 17+ in project.yml.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.
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 from async methods.Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub j-morgan6/ios-from-web-guide --plugin ios-from-web-guide