App Development with Swift study materials
App Development with Swift study resources

The Empty State That Lied

App Development with Swift · Put your knowledge to work in context.

All case studies

The Empty State That Lied

The Orders tab loads 12,000 orders from a paginated JSON API. OrderService.fetch is declared throws, but every call site uses try?, and the view model responds to failure by setting orders = []. Support tickets spike during outages: users see 'No orders yet' and an empty-state illustration. Selecting the 'Priority only' toggle freezes the list for about 3.8 seconds. The filter closure runs inside the cell provider and, for each visible order, evaluates orders.filter { $0.isPriority }.first(where: { $0.id == order.id }). Separately, a force-unwrapped URL(string: imagePath)! crashes on roughly 1 in 500 image paths, all of which contain spaces.

1. Quantify why the Priority toggle freezes and give a fix that removes the search entirely from the cell provider.

Read the worked answer

Count the work. For each of n cells the provider allocates a filtered array (O(n)) and then scans it (O(n)): about 2n² operations per reload. At n = 12,000 that is roughly 144 million predicate calls plus 12,000 array allocations, executed on the main thread during cell configuration. A 3.8-second freeze is expected; the symptom looks like layout, but the cause is algorithmic.

Cheapest fix - compute once per data change, not per cell: let priorityIDs = Set(orders.lazy.filter(\.isPriority).map(\.id)), then the cell asks priorityIDs.contains(order.id) in constant time.

Better - let the view model own the displayed array. On toggle, displayedOrders = orders.filter(\.isPriority). The cell provider then reads displayedOrders[indexPath.item] and performs no search at all. The provider becomes O(1) and the filter runs once.

If random access by id is frequent, build an index once: Dictionary(orders.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }). Use the uniquing initialiser rather than the trapping one, since an API that republishes orders can repeat ids. Keep decode and filter off the main thread; only snapshot application belongs on main.

2. Redesign the failure path so an outage cannot present as an empty account. State where `try?` remains acceptable.

Read the worked answer

try? erases both the error and the ability to distinguish 'the server said you have no orders' from 'we could not ask'. Mapping both to [] is the bug: the empty state is an assertion about data the code is not entitled to make.

Model the states and keep fetch throwing:

enum OrdersState { case loading, empty, loaded([Order]), failed(OrdersError) }

Use do/catch in the view model and map failures to .failed with a retry action. Reserve .empty for a successful response containing zero orders. Preserve the error's identity - offline, unauthorised, server(Int), decoding - so the UI can offer 'Sign in again' versus 'Retry' instead of one generic banner.

try? remains reasonable where the failure is genuinely uninteresting and local: a cache write, a best-effort analytics ping, an optional thumbnail prefetch. The distinguishing test is whether any user-visible decision depends on the outcome. At a network boundary that feeds a screen, it always does. Add a test asserting a 500 response yields .failed, not .empty.

3. Explain the `URL(string:)!` crash and set a policy for force unwrapping optionals that originate outside the app.

Read the worked answer

URL(string:) is failable because it parses and validates. A path containing a space is not a legal URL string, so the initialiser returns nil and the ! traps. The observed rate mirrors the share of user-generated or unescaped paths in the catalogue - it is data-dependent, which is why it passed QA.

Never force-unwrap data that crosses a system boundary. In order of preference: have the decoder supply a real URL, using URLComponents or addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) in a custom init(from:); or, if you must build from a string, do it once at the boundary and throw a decoding error so the single bad record is skipped or surfaced instead of crashing the app for all 12,000 orders.

A defensible ! guards invariants you control and can see - URL(string: "https://api.example.com/v1/orders")! on a literal. Anything derived from a response, a file, a pasteboard, or a user is not that. Treat force unwraps in decoding and networking code as review blockers, and consider a lint rule scoped to those directories.