How to create custom models in SwiftUI | SwiftUI Bootcamp #49
Real-world iOS apps display structured data — users, products, messages — not plain strings. Creating a custom struct model is the foundation of everything that comes after: view models, API decoding, persistent storage. After this lesson you'll know how to define a model, make it work with ForEach via Identifiable, and render its properties in a List.
What You'll Learn
- How to define a custom
structwith typed properties to represent a real-world entity - How the
Identifiableprotocol enablesForEachto track items uniquely without explicitid:parameters - How
UUID().uuidStringgenerates a stable, unique identifier for each model instance - How to conditionally render parts of a row based on model properties (e.g., showing a verified badge only for verified users)
Mental Model
Think of a custom model struct like a business card template. The template defines what fields exist: name, job title, phone number, company logo. Each specific business card is one instance of that template, filled in with real values. In Swift, the struct UserModel is the template; UserModel(displayName: "Nick", ...) is one filled-in business card.
Identifiable is like stamping each business card with a unique serial number on the back. ForEach needs those serial numbers to know which card is which — especially when cards are reordered, added, or removed. Without it, SwiftUI can't efficiently update the list; with it, SwiftUI can animate additions, deletions, and reorders correctly.
Detailed Explanation
A custom model in Swift is a struct (or class, but prefer struct for data) that groups related properties under a named type. Instead of managing four separate arrays — one for names, one for usernames, one for follower counts, one for verified flags — you define a UserModel that holds all four together. This is the Model in the Model-View architecture pattern.
The Identifiable protocol requires a single property: var id: SomeHashableType. By adding Identifiable, your struct works directly with SwiftUI's ForEach, List, and NavigationLink — you don't have to specify id: \.someProperty on every ForEach. SwiftUI uses the id to track items across re-renders, enabling correct animations and preventing the "cells jump around" bug that happens when items are identified by index.
let id: String = UUID().uuidString generates a random universally-unique identifier string at the moment the instance is created. This is the simplest way to give each model instance a permanent identity. If your data comes from an API or a database, use the server-provided ID instead — using UUID for server data creates mismatches between local and remote identities.
Keeping business logic out of the view struct is a key architectural principle. The UserModel here has only stored properties — no computed properties, no formatting methods, no network calls. The view (ModelBootcamp) handles display. As your app grows, logic moves into a view model class; but the model itself stays simple and data-only.
Code Structure
ModelBootcamp.swift defines two types. UserModel is a value-type struct conforming to Identifiable with four typed properties. ModelBootcamp is the view that owns an array of UserModel instances in @State and renders them in a List using ForEach. Each row uses multiple layout primitives — HStack, VStack, Circle, Image(systemName:) — to present the model's fields.
Complete Code
ModelBootcamp.swift
import SwiftUI
struct UserModel: Identifiable { // Identifiable enables parameter-free ForEach — no id: \.someField needed
let id: String = UUID().uuidString // auto-generated unique identifier; created once when the instance is initialized
let displayName: String
let userName: String
let followerCount: Int
let isVerified: Bool // drives conditional rendering of the checkmark badge
}
struct ModelBootcamp: View {
@State var users: [UserModel] = [
//"Nick", "Emily", "Samantha", "Chris" // ← what we'd have without a model: plain strings, no structure
UserModel(displayName: "Nick", userName: "nick123", followerCount: 100, isVerified: true),
UserModel(displayName: "Emily", userName: "itsemily1995", followerCount: 55, isVerified: false),
UserModel(displayName: "Samantha", userName: "ninja", followerCount: 355, isVerified: false),
UserModel(displayName: "Chris", userName: "chrish2009", followerCount: 88, isVerified: true)
]
var body: some View {
NavigationView {
List {
ForEach(users) { user in // works without id: parameter because UserModel is Identifiable
HStack(spacing: 15.0) {
Circle()
.frame(width: 35, height: 35) // placeholder avatar — would be an AsyncImage in a real app
VStack(alignment: .leading) {
Text(user.displayName)
.font(.headline)
Text("@\(user.userName)")
.foregroundColor(.gray)
.font(.caption)
}
Spacer()
if user.isVerified { // only renders the checkmark for verified users
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.blue)
}
VStack {
Text("\(user.followerCount)")
.font(.headline)
Text("Followers")
.foregroundColor(.gray)
.font(.caption)
}
}
.padding(.vertical, 10)
}
}
.listStyle(InsetGroupedListStyle()) // iOS-style card grouped appearance
.navigationTitle("Users")
}
}
}
struct ModelBootcamp_Previews: PreviewProvider {
static var previews: some View {
ModelBootcamp()
}
}Code Walkthrough
struct UserModel: Identifiable— Declares a custom data type conforming toIdentifiable. This is the model layer of our mini-app. It has no methods or computed properties — it's purely a data container.let id: String = UUID().uuidString— SatisfiesIdentifiable'sidrequirement.UUID()creates a random 128-bit universally unique identifier;.uuidStringconverts it to a readable hyphenated string like "550E8400-E29B-41D4-A716-446655440000". Usingletmeans the ID is fixed at creation time and never changes.let isVerified: Bool— A boolean property used to conditionally render the checkmark badge in the row. This is better than passing a string like "verified" or "unverified" — the type system ensures onlytrueandfalseare valid values.Commented-out plain string array — The comment
// "Nick", "Emily", ...shows what the data would look like without a model. You'd lose the type safety, the structured fields, and the ability to conditionally render properties likeisVerified. The model struct makes all of this possible.ForEach(users) { user in … }— BecauseUserModelconforms toIdentifiable, this works without specifyingid:. SwiftUI usesuser.idinternally to track each row. Adding or removing items from theusersarray would produce correctly animated list updates.if user.isVerified { Image(systemName: "checkmark.seal.fill") }— Conditional rendering based on a model property. This is declarative: the view describes "show the badge if verified" and SwiftUI handles the show/hide logic. In UIKit you'd manually callimageView.isHidden = !user.isVerified— SwiftUI's version is more expressive..listStyle(InsetGroupedListStyle())— Applies the iOS-style inset grouped card style to the list, where items appear in a rounded rectangle card rather than full-width rows. This is a common style for settings screens and user lists.
Common Mistakes
Mistake: Using an array of strings or a parallel array of separate properties instead of a model struct
Parallel arrays (names[i], usernames[i], followerCounts[i]) are fragile — they must always have the same count and corresponding indices. A model struct groups related data and makes it impossible to have a name without a username. Always prefer a typed struct for anything with more than one property.
Mistake: Using mutable indices or row position as the id: in ForEach instead of a stable identifierForEach(users.indices, id: \.self) works but uses the array position as identity. If you delete item 0, all other items shift down — their positions change, causing incorrect animations and potential state corruption. Always use a stable, content-based ID (UUID or a server ID) rather than a positional index.
Mistake: Adding display logic or formatting code inside the model struct
Model structs should hold data, not presentation logic. Formatting followerCount as "1.2K" or "355 followers" belongs in the view or view model, not in UserModel. Keeping models data-only makes them easier to decode from JSON, test, and reuse across different views with different formatting needs.
Key Takeaways
- Define custom
structtypes with typed properties to represent real entities — this is more robust and expressive than plain strings or parallel arrays. - Conform to
Identifiableusing a stable unique ID (UUID or server ID) soForEachcan track items correctly across additions, deletions, and reorders. - Keep model structs data-only: no formatting, no display logic, no network calls — that belongs in the view or a view model layer.
Last updated: June 27, 2026