← All daysDay 31 of 49 · Part 8: Data, persistence, and networking
Day 31

AppStorage and SceneStorage

These are the lesson notes for this day. Its build-along tutorial and flash cards have not been written yet.

Lesson 35

AppStorage and SceneStorage

@AppStorage("key") reads and writes a value in UserDefaults, and updates the view when the value changes. It is for user preferences. @SceneStorage("key") saves small pieces of interface state for each window — the selected tab, a scroll position, a draft — so that the system can restore them later. Both are meant for small, simple values. Anything larger or more structured belongs in SwiftData or in a file.

Exercise

Remember the selected tab with @SceneStorage, and a "compact rows" preference with @AppStorage. Quit and relaunch to confirm that both are restored.

After this lesson you should be able to answer
  1. Where does @AppStorage keep its value?
  2. What is the difference between @AppStorage and @SceneStorage?
  3. What kind of data should go in neither?
Resources
Write the code
Interview problem · Part 8

The take-home, in miniature

“Here is items.json. Decode it, show the items in a list with their remote thumbnails, and handle loading and failure properly. Forty minutes. I care more about your decisions than your pixels.”

{ "items": [
    { "id": 7, "name": "Dungeness Crab", "price_cents": 2450,
      "image_url": "https://example.com/crab.jpg", "tags": ["seafood", "local"] },
    { "id": 8, "name": "Oysters", "price_cents": 1800,
      "image_url": null, "tags": [] }
] }

Where this comes from: DoorDash iOS interview, as reported by candidates (Prepfully) — DoorDash's take-home, as reported: an Xcode project, a JSON sample file and a three-line loader; build the UI, images included

Show the answer
struct Menu: Decodable { let items: [Item] }
struct Item: Decodable, Identifiable {
    let id: Int
    let name: String
    let priceCents: Int
    let imageURL: URL?
    let tags: [String]

    enum CodingKeys: String, CodingKey {
        case id, name, tags
        case priceCents = "price_cents"
        case imageURL = "image_url"
    }
}

enum Loadable<Value> {
    case loading
    case loaded(Value)
    case failed(String)
}

@MainActor @Observable
final class MenuModel {
    private(set) var state: Loadable<[Item]> = .loading
    // the seam: bundle file, URLSession, or a test stub
    private let load: () async throws -> Data

    init(load: @escaping () async throws -> Data) { self.load = load }

    func refresh() async {
        state = .loading
        do {
            let data = try await load()
            state = .loaded(try JSONDecoder().decode(Menu.self, from: data).items)
        } catch {
            state = .failed("Couldn't load the menu.")
        }
    }
}

struct MenuView: View {
    let model: MenuModel

    var body: some View {
        Group {
            switch model.state {
            case .loading:
                ProgressView()
            case .failed(let message):
                ContentUnavailableView(message, systemImage: "wifi.slash")
            case .loaded(let items):
                List(items) { item in
                    HStack(spacing: 12) {
                        AsyncImage(url: item.imageURL) { image in
                            image.resizable().scaledToFill()
                        } placeholder: {
                            Color.gray.opacity(0.2)
                        }
                        .frame(width: 56, height: 56)
                        .clipShape(.rect(cornerRadius: 8))

                        VStack(alignment: .leading) {
                            Text(item.name)
                            Text(Decimal(item.priceCents) / 100, format: .currency(code: "USD"))
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
        }
        .task { await model.refresh() }
    }
}

Decisions to say out loud as you go. Explicit CodingKeys, because .convertFromSnakeCase would turn image_url into imageUrl, not imageURL, and the property would quietly fail to decode. An optional URL, so that one null image cannot fail the whole list. One Loadable enum rather than three Booleans that can contradict one another. And the loader passed in as a closure, so the same model reads a bundled file today, URLSession tomorrow, and a stub in tests.

Finish by saying what you would test first: decoding the sample (including the null image), and the model's move from .loading to .loaded and to .failed. None of that needs a view.

What interviewers listen for: That states which cannot happen cannot be represented, that failure is designed rather than ignored, and that you explain your trade-offs instead of typing in silence.