Help us improve
Share bugs, ideas, or general feedback.
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.
npx claudepluginhub j-morgan6/ios-from-web-guide --plugin ios-from-web-guideHow 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.
Creates p5.js generative art with seeded randomness, noise fields, and interactive parameter exploration. Use for algorithmic art, flow fields, or particle systems.
Share bugs, ideas, or general feedback.
@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.