Reeler: rubric and defend your codeswift-6.4/ios-26
Lesson 6 / 6
Unit 24 · Reeler

Reeler: rubric and defend your code

Note

Unit 24 · Project 4, rubric. Score yourself honestly against each row, then answer the defend-your-code prompts as if a senior engineer were reviewing your PR. If you can't defend a decision, that's the part to revisit before moving to Project 5.

A finished project isn't "it runs." It's "it runs, and I can explain why every non-obvious choice is the right one." This lesson is the checklist and the viva. Run /ship-check first to confirm the mechanical gates, then work the rubric.

Rubric

Score each row: 2 solid, 1 partial, 0 missing. Aim for 18+ of 22.

# Criterion What "solid" looks like
1 Injected client SearchViewModel takes a FilmAPIClient protocol; a stub is used in tests/previews
2 View model isolation View model is @Observable @MainActor; state is a single enum, not loose flags
3 Debounced search Keystrokes don't each fire a request; latest keystroke wins; cancellation swallowed
4 Value-based navigation NavigationLink(value:) + navigationDestination(for:); Film is Hashable
5 Pagination correctness Load-more with an in-flight guard; no duplicate rows, no runaway page counter
6 Actor image cache Cache is an actor; concurrent loads are safe; downloads are coalesced
7 No retain cycles Stored closures that reference their owner capture self weakly
8 SwiftData film data @Model FilmActivity with a unique key; rating/like/watchlist/review persist; works offline
9 Views as filters Diary / Watchlist / Profile are @Query filters over one FilmActivity store, not separate stores
10 Bindable editing Rating, like, watchlist, and review write straight to the model via @Bindable — no manual save
11 Tokens + design contract No raw hex, no gradients/glow; SF Symbols only; light and dark both authored
12 Reviewed AI diffs You ran /swift-review on non-trivial AI-proposed code and rejected what failed

Defend your code

Answer each aloud or in writing. The goal is a crisp, correct sentence — the kind you'd give in a real review.

Why is SearchViewModel marked @MainActor? Because it holds UI state SwiftUI reads on the main thread, and it mutates that state after awaiting the network. @MainActor guarantees those mutations happen on the main actor with no manual thread-hopping, and makes the read-modify-write on isLoadingPage atomic with respect to the UI. Without it, you'd have to hop to the main actor by hand on every assignment and risk a data race on the paging flags.

Why is the image cache an actor but the view model a @MainActor class? Different concurrency needs. The cache is touched by many background image loads at once, so it needs its own isolation domain that serializes concurrent access — an actor. The view model is touched by the UI, which is inherently main-actor, so it belongs on the main actor. Putting the cache on the main actor would drag every download's bookkeeping onto the main thread; putting the view model on a custom actor would force an await on every UI read.

Why is Film a struct, and why does that matter for concurrency? Value semantics: a Film handed from the API client (a background context) to the main-actor view model is a copy nobody else can mutate, so it's Sendable for free and crosses the actor boundary safely. A class would have to be made Sendable deliberately (and justified).

Where did you break a retain cycle, and how did you know it was one? Anywhere an object stored a closure that referred back to that same object — the loader's stored onReload. You know it's a cycle when the object holds the closure (a stored property) and the closure holds the object (a strong self capture). The fix is [weak self] plus guard let self.

Carry-forward

Reeler consumed someone else's API. Project 5 (Snapgram) gives you a backend of your own — Supabase auth, a Postgres database with Row-Level Security, and storage for photo uploads. The architecture you just built (injected client, @MainActor view model, actor for shared caches, SwiftData for local truth) carries directly; you'll swap the read-only film client for a Supabase client you authenticate against. The Pair-mode review habit carries too — Project 5's AI checkpoint seeds a nastier flaw: a data race that only bites under concurrent load.

Knowledge check

Q: Your Diary shows nothing in airplane mode. Is the app broken? It should not be — your film data is SwiftData, read locally by @Query, with no network dependency. If it vanishes offline, you're re-fetching from the network instead of reading the local store, which defeats the offline goal. The local FilmActivity store is the source of truth for everything you've logged.

Q: Why is the Diary not its own @Model, separate from the Watchlist? Because they're the same data seen two ways — logged films vs. watchlisted films. One FilmActivity store with @Query filters keeps a single source of truth, so a rating set on the detail page shows up in the Diary and the Profile average with nothing to synchronize.

Q: What is the single most important thing to carry from Reeler into the backend projects? Dependency injection behind a protocol. It's what let you test the view model with a stub instead of the network, and it's exactly the seam where a FilmAPIClient becomes a SupabaseClient in the next project without rewriting the view model.