100 days of Swift, SwiftUI and iOS apps: the recap explained
After a hundred days of building with Swift, SwiftUI and iOS, we've learned that shipping production apps isn't about chasing the latest framework features—it's about respecting the platform's design philosophy and your users' expectations. The lessons that matter most aren't flashy; they're foundational. This recap walks through the principles that separated our smoother launches from the painful ones, the architectural decisions that scaled gracefully, and the tools that caught bugs before customers found them.
Swift is safe by design — Optionals, value types and strict concurrency
Swift's type system isn't a bureaucratic hurdle; it's a force multiplier. The language leans hard into safety, and the three pillars—optionals, value types, and strict concurrency—aren't optional concerns we can skip in the rush to ship.
Optionals force you to acknowledge uncertainty. Early in the hundred days, we'd reach for force-unwrapping and implicitly-unwrapped optionals when schedules tightened. Every single time, that came back to haunt us. A nil in production is a crash in production. The discipline of handling optionals—using guard, if let, and nil coalescing—means your app degrades gracefully instead of terminating.
// Bad: forces a crash if userID is nil
let user = fetchUser(id: userID!)
// Good: handles the nil case explicitly
guard let userID = userID else {
showErrorState()
return
}
let user = fetchUser(id: userID)Value types (structs and enums) prevent the shared mutable state bugs that plague object-oriented codebases. When you pass a struct, you pass a copy. That's not overhead; that's correctness built in. We learned to reach for structs first, then classes only when reference semantics were genuinely necessary—which is rarely. ViewModels in SwiftUI should almost always be value types or observed objects, not mutable reference types shared across screens.
Strict concurrency (with @MainActor, async/await, and actor isolation) prevents data races that are nightmarish to debug. Swift 5.5+ enforce concurrency constraints at compile time. That means race conditions that would surface unpredictably in production are caught before you even run the app locally. We adopted these patterns incrementally; the investment in marking UI code @MainActor and background work nonisolated paid off immediately.
@MainActor
class OrderViewModel: ObservableObject {
@Published var orders: [Order] = []
func loadOrders() async {
do {
self.orders = try await fetchOrders()
} catch {
// handle error on main thread
}
}
}
// Background work stays off the main thread automatically
nonisolated func processPayment() async {
let result = await paymentGateway.process(...)
await MainActor.run {
self.handleResult(result)
}
}SwiftUI is state-driven — Own state clearly and keep views small
SwiftUI's declarative model is powerful, but it requires a shift in thinking. Views are not objects that you mutate; they are functions of state. The cleaner your state management, the less mystery when the UI doesn't update or updates twice.
Over a hundred days, we found that bloated view bodies are the root of performance problems and bugs. A view file that scrolls past 200 lines is a red flag. Break it into smaller views, each with a single responsibility. Each subview should own its slice of state, not borrow it from a parent and return modified copies.
// Bad: large, monolithic view that owns too much state
struct OrderListView: View {
@StateObject private var viewModel = OrderListViewModel()
@State private var showFilters = false
@State private var selectedSort = "date"
@State private var searchText = ""
var body: some View {
VStack {
// 50 lines of filter UI
// 80 lines of list UI
// 40 lines of detail UI
}
}
}
// Good: split into focused views
struct OrderListView: View {
@StateObject private var viewModel = OrderListViewModel()
var body: some View {
VStack {
OrderFilterBar(viewModel: viewModel)
OrderListContent(viewModel: viewModel)
}
}
}
struct OrderListContent: View {
@ObservedRealmObject var viewModel: OrderListViewModel
var body: some View {
List(viewModel.filteredOrders) { order in
OrderRow(order: order)
}
}
}State should live as high as necessary but as low as possible. If only a detail view needs a flag, don't hoist it to the list view. Use @State for local ephemeral state, @StateObject for view models, and @EnvironmentObject sparingly for app-wide data like authentication context. We made mistakes over-using environment objects early; they become invisible dependencies that make refactoring painful.
Keep view bodies predictable. Use @State to hold the minimum necessary data, and let the view recompute from that state. Avoid complex conditionals and side effects inside body. If you need to run code when state changes, use .onChange(of:perform:) or a view model's published properties.
Platform features delight users — Widgets, notifications and App Intents
The difference between a good app and one users love isn't always in the core product—it's in the platform integrations. Widgets let users see information without opening the app. Notifications keep users informed. App Intents let Siri and Shortcuts make the app smarter.
Early in our hundred days, we treated these as afterthoughts. That was a mistake. Users judge apps by the small touches: a home screen widget that shows the next order arriving, a notification when a payment processes, or a Siri shortcut to reorder a favorite item. These integrations are not fluff; they're table stakes for retention.
Widgets run in a sandboxed environment with limited resources. They can't perform network requests directly; they rely on background app refresh or shared app groups to sync data. We use WidgetKit with Timelines to update widgets at predictable intervals, and we cache widget data in an app group container so the widget can read fresh data without waking the app.
// Simple widget timeline that updates hourly
struct OrderWidgetProvider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
let sharedDefaults = UserDefaults(suiteName: "group.com.myapp")
let nextOrder = sharedDefaults?.string(forKey: "nextOrderETA") ?? "Unknown"
let entry = SimpleEntry(date: Date(), nextOrder: nextOrder)
let nextRefresh = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
let timeline = Timeline(entries: [entry], policy: .after(nextRefresh))
completion(timeline)
}
}Notifications keep users engaged, but only if they're timely and relevant. We use UserNotifications for local notifications and a backend for remote push. The key insight: notifications that ship on schedule but don't matter will be muted. Test notification copy with real users. Make them actionable—include notification actions so users can respond without opening the app.
App Intents (new in iOS 16+) replace the older Shortcuts framework. They let you define the actions your app can perform and expose them to Siri, Shortcuts, and the lock screen. This is where the app becomes part of the system, not an isolated island.
struct PlaceOrderIntent: AppIntent {
@Parameter(title: "Item") var item: String
static var title: LocalizedStringResource = "Place Order"
static var description: IntentDescription = "Order your favorite item"
func perform() async throws -> some IntentResult {
let order = try await orderService.placeOrder(for: item)
return .result(value: "Order placed: \(order.id)")
}
}Ship carefully — Tests, Instruments, TestFlight and phased releases
The difference between a launch that goes smoothly and one that becomes a firefighting exercise is preparation. Testing, profiling, and staged rollouts aren't bureaucracy; they're insurance.
Unit tests catch logic errors before code review. We aim for coverage of business logic and critical paths—not every line, but the paths that, if broken, would be catastrophic. View models are easy to test; views themselves are harder. We focus tests on the logic in ObservableObject classes and lean on UI tests for the view layer.
class OrderViewModelTests: XCTestCase {
func testOrderTotalCalculation() throws {
let viewModel = OrderViewModel()
viewModel.items = [
OrderItem(price: 10.0, quantity: 2),
OrderItem(price: 5.0, quantity: 1)
]
XCTAssertEqual(viewModel.total, 25.0)
}
func testApplyCouponReducesTotal() async throws {
let viewModel = OrderViewModel()
viewModel.items = [OrderItem(price: 100.0, quantity: 1)]
try await viewModel.applyCoupon("SAVE10")
XCTAssertEqual(viewModel.total, 90.0)
}
}Instruments reveal performance problems that tests miss. Memory leaks, excessive view redraws, main thread blocking—these show up in Instruments' timeline. We profile before launch and again if users report sluggishness. The time invested in profiling early pays back tenfold in post-launch sanity.
TestFlight is your first line of defense against real-world surprises. We push to TestFlight at least two weeks before a public release and actively solicit feedback from our internal team and trusted external testers. TestFlight crashes are caught and reported automatically; we fix critical ones before going public.
Phased releases on the App Store reduce risk. Instead of releasing to 100% of users at once, start at 5% or 10%. Monitor crashes, ratings, and review sentiment. If nothing goes wrong, ramp up. This gives you a window to catch and fix issues before they affect millions of users.
Common mistakes
- Force-unwrapping optionals under deadline pressure. This trades a short-term speedup for crashes in production. It's never worth it.
- Ignoring SwiftUI view performance. Complex view hierarchies and frequent state mutations cause stuttering. Profile early and keep views simple.
- Treating platform features as nice-to-haves. Widgets, notifications, and App Intents are user expectations, not bonuses. Plan time for them in your roadmap.
- Skipping TestFlight or rushing through it. TestFlight is your reality check. Don't short-circuit it.
- Over-centralizing state with environment objects. It feels convenient, but it creates invisible dependencies that make refactoring a nightmare.
Key takeaways
- Swift's safety features—optionals, value types, and strict concurrency—are not obstacles. They prevent classes of bugs that would otherwise haunt production.
- SwiftUI state must be owned clearly and views must be small. Large view files with unclear state flow are the source of most bugs and performance issues.
- Platform integrations—widgets, notifications, and App Intents—are what separate polished apps from basic ones. Plan time for them.
- Testing and profiling catch problems before users do. TestFlight and phased releases give you a safety net. Use them.
- Shipping well is about discipline and respect for the platform, not heroic last-minute efforts.






Comments
Loading comments…