- Read and write JSON with
Codablestructs - Write a network request with
URLSessionandasync/awaitand handle errors - Connect loading to the screen with
.taskand.refreshable - Work with
@Model,@QueryandmodelContextin SwiftData
Elvin opens Educora on a plane. There is no internet, but the notes he wrote yesterday are still there, and after landing the app quietly loads new lessons from the server. This takes two skills: getting data from the network (JSON, URLSession, async/await) and storing it on the device (SwiftData). In this lesson we will build both step by step.
Codable: from JSON to Swift and back
Servers usually send data as JSON text. If you declare a struct as conforming to Codable, Swift learns by itself how to read it from JSON (JSONDecoder) and write it to JSON (JSONEncoder) — not a single line of extra code is needed. Property names must match the keys in the JSON; extra keys in the JSON are simply skipped.
import Foundation
struct Lesson: Codable {
let id: Int
let title: String
let minutes: Int
}
let json = """
[{"id": 1, "title": "Variables", "minutes": 10},
{"id": 2, "title": "Loops", "minutes": 15, "level": "beginner"}]
"""
let lessons = try JSONDecoder().decode([Lesson].self, from: Data(json.utf8))
for lesson in lessons {
print("\(lesson.id). \(lesson.title) - \(lesson.minutes) min")
}
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(lessons[0])
print(String(decoding: data, as: UTF8.self))1. Variables - 10 min
2. Loops - 15 min
{"id":1,"minutes":10,"title":"Variables"}level key is skipped because the struct does not have it; .sortedKeys writes the keys in alphabetical order.URLSession and async/await
URLSession.shared.data(from:) is an async function: the word await marks where it waits for the answer, but the main thread is not blocked meanwhile — the screen stays responsive. The function returns a (Data, URLResponse) pair. If the network fails it throws an error, so the call also needs try. The example loads to-dos from JSONPlaceholder, a public service for testing.
struct Todo: Codable, Identifiable {
let id: Int
let title: String
let completed: Bool
}
enum APIError: Error {
case badStatus(Int)
}
func fetchTodos() async throws -> [Todo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
let (data, response) = try await URLSession.shared.data(from: url)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw APIError.badStatus(http.statusCode)
}
return try JSONDecoder().decode([Todo].self, from: data)
}decode throws.func fetchTodos(completion: @escaping (Result<[Todo], Error>) -> Void) {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
URLSession.shared.dataTask(with: url) { data, _, error in
if let error {
completion(.failure(error))
return
}
do {
let todos = try JSONDecoder().decode([Todo].self, from: data ?? Data())
completion(.success(todos))
} catch {
completion(.failure(error))
}
}.resume()
}func fetchTodos() async throws -> [Todo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Todo].self, from: data)
}.resume() means the request is never sent, and missing one completion call leaves the screen “loading” forever. On the right the code reads top to bottom and errors travel up by themselves through throws.@MainActor
@Observable
final class TodosModel {
var todos: [Todo] = []
var isLoading = false
var errorMessage: String?
func load() async {
isLoading = true
defer { isLoading = false }
do {
todos = try await fetchTodos()
errorMessage = nil
} catch {
errorMessage = "Could not load the list"
}
}
}@MainActor guarantees that the model's properties change only on the main thread — which is where the UI is updated.struct TodosView: View {
@State private var model = TodosModel()
var body: some View {
List(model.todos) { todo in
Label(todo.title, systemImage: todo.completed ? "checkmark.circle.fill" : "circle")
}
.overlay {
if model.isLoading { ProgressView() }
}
.task { await model.load() }
.refreshable { await model.load() }
}
}The defer block runs at the end no matter how the function exits — successfully or with an error — so the indicator never gets “stuck” on screen. When errorMessage is set, it is handy to show iOS 17's ready-made empty-state view instead of the list: ContentUnavailableView("No connection", systemImage: "wifi.slash"). You can put a “Try again” button under it that calls load() again.
SwiftData: saving on the device
SwiftData (iOS 17+) is the shortest way to store a Swift class in a database. You write @Model in front of the class, create a store in the app with .modelContainer(for:), and read the data in a view with @Query. A new object is added with modelContext.insert(...) and removed with delete; changes are saved automatically, and @Query refreshes the list by itself.
import SwiftData
import SwiftUI
@Model
final class Note {
var text: String
var createdAt: Date
init(text: String, createdAt: Date = .now) {
self.text = text
self.createdAt = createdAt
}
}
@main
struct EducoraNotesApp: App {
var body: some Scene {
WindowGroup {
NotesView()
}
.modelContainer(for: Note.self)
}
}struct NotesView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]
var body: some View {
NavigationStack {
List {
ForEach(notes) { note in
Text(note.text)
}
.onDelete { offsets in
for index in offsets { context.delete(notes[index]) }
}
}
.navigationTitle("Notes")
.toolbar {
Button("Add", systemImage: "plus") {
context.insert(Note(text: "New note"))
}
}
}
}
}+ adds a note, and swiping left deletes one. When you close and reopen the app, the notes are still there.| What to store | Where |
|---|---|
| small settings: theme, daily goal | @AppStorage (UserDefaults) |
| notes, results, lists you search | SwiftData |
| passwords and sign-in tokens | Keychain |
| progress that must match on every device | the server |
@AppStorage("dailyGoal") private var dailyGoal = 15 keeps the value even after the app is closed.- 1A project with SwiftData
When creating a new project, choose
SwiftDatain theStoragefield — Xcode generates a sampleItemmodel, the.modelContainerand a list screen for you. - 2An in-memory store for previews
So that the preview doesn't touch the real database, write:
#Preview { NotesView().modelContainer(for: Note.self, inMemory: true) }. - 3Step through with the debugger
Set a breakpoint inside
load(), run the app and step line by line withF6: you will see how thetodosarray fills up.
Key points
- A
Codablestruct is read from JSON withJSONDecoderand written withJSONEncoder; extra keys are skipped. try await URLSession.shared.data(from:)returns(Data, URLResponse)and does not block the screen.- The model is
@MainActor @Observable;.taskstarts loading and cancels itself, and.refreshableadds pull to refresh. - SwiftData: an
@Modelclass,.modelContainer(for:)in the app, and@QueryplusmodelContext.insert/deletein the view. - Small settings go in
@AppStorage, passwords in the Keychain; use HTTPS only.
Check yourself
10 questions. Every correct answer earns XP.
try await URLSession.shared.data(from: url) return?