Skip to content
Educora
Advanced25 min13 / 14

Networking and persistence

Turn JSON into Swift types with Codable, load data from a server with URLSession and async/await, show the result on screen, and save notes on the device with SwiftData.

Check yourself
In this lesson you will learn
  • Read and write JSON with Codable structs
  • Write a network request with URLSession and async/await and handle errors
  • Connect loading to the screen with .task and .refreshable
  • Work with @Model, @Query and modelContext in 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.

Swift
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))
Expected output
1. Variables - 10 min
2. Loops - 15 min
{"id":1,"minutes":10,"title":"Variables"}
Runs in Swift Playgrounds or on a Mac. The 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.

Swift
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)
}
If the server returns a status outside 200–299, we throw our own error; if the JSON does not match, decode throws.
Old: a completion handler
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()
}
New: async/await
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)
}
On the left, forgetting .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.
Swift
@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.
Swift
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() }
    }
}
On screen: first a spinning indicator, then a list of to-dos with filled or empty circles; pulling the list down reloads it.

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.

Swift
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)
    }
}
Swift
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"))
                }
            }
        }
    }
}
On screen: a “Notes” list with the newest note on top; + adds a note, and swiping left deletes one. When you close and reopen the app, the notes are still there.
What to storeWhere
small settings: theme, daily goal@AppStorage (UserDefaults)
notes, results, lists you searchSwiftData
passwords and sign-in tokensKeychain
progress that must match on every devicethe server
For example: @AppStorage("dailyGoal") private var dailyGoal = 15 keeps the value even after the app is closed.
  1. 1
    A project with SwiftData

    When creating a new project, choose SwiftData in the Storage field — Xcode generates a sample Item model, the .modelContainer and a list screen for you.

  2. 2
    An in-memory store for previews

    So that the preview doesn't touch the real database, write: #Preview { NotesView().modelContainer(for: Note.self, inMemory: true) }.

  3. 3
    Step through with the debugger

    Set a breakpoint inside load(), run the app and step line by line with F6: you will see how the todos array fills up.

go to the next line in the debugger — Step OverF6
go into the called function — Step IntoF7
continue running after a pauseCtrl+Cmd+Y
search the whole projectCmd+Shift+F

Key points

  • A Codable struct is read from JSON with JSONDecoder and written with JSONEncoder; extra keys are skipped.
  • try await URLSession.shared.data(from:) returns (Data, URLResponse) and does not block the screen.
  • The model is @MainActor @Observable; .task starts loading and cancels itself, and .refreshable adds pull to refresh.
  • SwiftData: an @Model class, .modelContainer(for:) in the app, and @Query plus modelContext.insert/delete in the view.
  • Small settings go in @AppStorage, passwords in the Keychain; use HTTPS only.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
What does try await URLSession.shared.data(from: url) return?