agentsclimarketplace

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

Install
npx -y skills add j-morgan6/iOS-from-web-guide --skill swiftui-observable-viewmodel-boilerplate

Assembled 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

  1. Use @Observable (Swift Observation, iOS 17+). Never @ObservableObject / @Published / @StateObject / @ObservedObject. This rule is enforced by the published-on-ios17 hook.
  2. Every ViewModel is @MainActor @Observable final class. @MainActor because views read it on the main thread; final because subclassing an Observable breaks tracking.
  3. Views hold ViewModels via @State, not @StateObject or @ObservedObject. @State is the correct property wrapper for @Observable reference types since iOS 17.
  4. One ViewModel per screen. A view doesn't share a ViewModel with its parent — if it needs parent data, pass values in the initializer.
  5. Public mutable properties; no @Published. The macro tracks every stored property automatically. Add explicit @ObservationIgnored only on caches or other internal state that shouldn't trigger re-renders.
  6. Async methods do the network work. The View calls .task { await viewModel.load() }, not onAppear { viewModel.load() }.
  7. Loading / data / error state is explicit. Three state properties: isLoading: Bool, the data (items: [T]), and errorMessage: String?. No nil-as-sentinel loading states.

When to invoke

  • Creating a new file under ViewModels/.
  • Converting a pre-iOS-17 @ObservableObject ViewModel 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:

  1. Check the class has @Observable — not @ObservableObject.
  2. Check the view uses @State — not @StateObject.
  3. Check the property is stored (not computed). Observation doesn't track computed properties unless they read other tracked properties during evaluation.
  4. Check deploymentTarget.iOS is 17+ in project.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 from async methods.

Keep looking

Skills are one crate of 628,853. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.