AppStorage and SceneStorageThese are the lesson notes for this day. Its build-along tutorial and flash cards have not been written yet.
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.
Remember the selected tab with @SceneStorage, and a "compact rows" preference with @AppStorage. Quit and relaunch to confirm that both are restored.
@AppStorage keep its value?@AppStorage and @SceneStorage?“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
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.