From ios-from-web-guide
MANDATORY for model structs used in SwiftUI views, especially with NavigationLink(value:) or @Observable arrays. Invoke before writing custom Equatable or Hashable conformances on model types.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ios-from-web-guide:swiftui-equatable-hashable-for-diffingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **Let Swift auto-synthesize `Equatable` and `Hashable` on model structs** by declaring the conformance without an explicit implementation. Auto-synthesis gives you **structural equality** — the behavior SwiftUI's diffing actually needs.
Equatable and Hashable on model structs by declaring the conformance without an explicit implementation. Auto-synthesis gives you structural equality — the behavior SwiftUI's diffing actually needs.== that only compares id on a model type used by SwiftUI. This silently breaks re-renders when non-id fields mutate.Hashable. If Post contains [Ingredient], then Ingredient must be Hashable too — otherwise synthesis fails and you're tempted to write a custom bad ==.NavigationPath routing keys), wrap the id in a dedicated Identifiable-only type and use that as the navigation value. Don't pollute the model itself.struct-level Equatable with an extension override. If you write extension Post: Equatable { static func == ... }, you've turned off auto-synthesis and every future field you add is quietly excluded from equality.NavigationLink(value:).The real bug this skill prevents:
// ❌ The subtle bug
struct Post: Identifiable, Hashable {
let id: Int
var title: String
var isBookmarked: Bool
static func == (lhs: Post, rhs: Post) -> Bool {
lhs.id == rhs.id // id-only equality — BAD
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
toggleBookmark mutates isBookmarked, but since the replaced post compares == to the old one (same id), SwiftUI's diffing decides the view didn't change and skips re-render. The server updates; the UI stays stale. Force-quit and relaunch shows the correct state.
The fix: auto-synthesize.
// ✅
struct Post: Identifiable, Hashable, Codable, Sendable {
let id: Int
var title: String
var isBookmarked: Bool
var author: User // User must be Hashable
var ingredients: [Ingredient] // Ingredient must be Hashable
}
No custom ==. Swift synthesizes structural equality from all stored properties. isBookmarked changes → posts are unequal → SwiftUI re-renders.
struct User: Identifiable, Hashable, Codable, Sendable {
let id: Int
var username: String
var avatarURL: String?
}
struct Ingredient: Hashable, Codable, Sendable {
let name: String
let amount: String
}
If any nested type is not Hashable, synthesis silently fails and Xcode surfaces a compile error like "Type 'Post' does not conform to protocol 'Hashable'". Fix the leaf type, not the parent.
Sometimes NavigationPath or .id(...) really does want identity-only comparison. Don't override the model's == — introduce a wrapper:
struct PostRoute: Hashable, Identifiable {
let id: Int
}
// Navigation value
NavigationLink(value: PostRoute(id: post.id)) { FeedCardView(post: post) }
// Root destination
.navigationDestination(for: PostRoute.self) { route in
PostDetailLoader(postId: route.id) // fetches the post
}
Now Post remains structurally equatable (so lists re-render correctly) and the navigation layer uses a separate identity-only type.
==When @Observable ViewModel's array mutates, SwiftUI diffs the old and new values of properties read during body. For each element it checks ==. If == says "equal," the child view is not re-rendered. Custom id-only == therefore produces stale UI for every post field that isn't the id.
// Week 1:
struct Post: Hashable { let id: Int; var title: String }
// Week 2: added `var likeCount: Int`
// ✅ Auto-synthesis picks up the new field automatically.
This only works because there's no custom ==. If there were one, likeCount would be silently excluded.
Hashable on an array element typeSymptom: error: type 'Post' does not conform to protocol 'Hashable'.
Cause: Some nested type (often added months later, like var tags: [Tag]) is missing Hashable.
Fix: Add Hashable to the leaf type. Don't override == on the parent to paper over it.
NavigationLink(value:) fails with "not Hashable"Same root cause. Either declare the type Hashable (auto-synthesis) or wrap the id in a PostRoute-style struct.
[Post] in @Observable doesn't trigger updatesCheck you aren't reassigning the whole array to a functionally-equal value via a custom ==. Auto-synthesis on structural equality is what makes posts[idx] = updated trigger a view refresh.
No dedicated template — the rule is structural: declare Hashable on the type and don't write custom ==.
swiftui-navigation-foundations — navigation values must be Hashable.swiftui-observable-viewmodel-boilerplate — view models hold arrays of these models.swiftui-optimistic-ui-pattern — mutation patterns rely on structural equality to trigger re-renders.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