I’m getting back into iOS development after some time away and I’m confused by all the new tools, patterns, and frameworks like SwiftUI, Combine, and async/await. My old UIKit knowledge only gets me so far and I’m not sure what I should focus on first to build production-ready apps. Can someone explain the current best practices and learning path for modern iOS development so I don’t waste time on outdated approaches?
Short version. Your UIKit knowledge still matters. You only need a few new pieces:
- SwiftUI vs UIKit
– For new personal apps, use SwiftUI first.
– For existing UIKit apps, add SwiftUI slowly with UIHostingController.
– UIKit is not dead. Apple uses it a lot under the hood.
Core idea in SwiftUI:
- You describe the UI as a function of state.
- @State for local view state.
- @ObservedObject / @StateObject for view models.
- @EnvironmentObject for shared stuff.
Start simple:
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text('Count: \(count)')
Button('Increment') {
count += 1
}
}
}
}
Learn:
- View life cycle is different from UIViewController.
- No direct layout constraints, you use stacks, spacers, frames.
- Use previews heavily, they save time.
- Combine vs async/await
Think of Combine as Rx-lite for Apple. Streams, publishers, operators.
Think of async/await as “normal” async code with nice syntax.
Modern pattern on iOS 17+
- Prefer async/await for new async work.
- Use Combine only when you need streams over time, like search text, notifications, or multiple subscribers.
Example with async/await instead of completion handler:
func fetchUser() async throws → User {
let url = URL(string: ‘https://api.test.com/user’)!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
SwiftUI + async/await:
@MainActor
final class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var error: String?
func load() async {
isLoading = true
defer { isLoading = false }
do {
user = try await fetchUser()
} catch {
self.error = error.localizedDescription
}
}
}
struct UserScreen: View {
@StateObject private var vm = UserViewModel()
var body: some View {
Group {
if vm.isLoading {
ProgressView()
} else if let user = vm.user {
Text(user.name)
} else if let error = vm.error {
Text(error)
} else {
Text('No data')
}
}
.task {
await vm.load()
}
}
}
- Concurrency rules you should know
- Use async/await.
- Mark UI facing view models as @MainActor.
- Use Task { } in SwiftUI to kick off async work.
- Use Task.detached only when you know why. Most people do not need it.
Actor example for shared mutable state:
actor SessionStore {
private(set) var token: String?
func updateToken(_ new: String?) {
token = new
}
}
- What to learn in order
Step 1, Language
- Swift 5.7+ features: async/await, async let, structured concurrency, Sendable, Result Builders.
- Optionals, value types, protocols, generics, if you skipped them before.
Step 2, SwiftUI basics
- Views, modifiers, stacks, lists, navigation stacks.
- State management: @State, @Binding, @ObservedObject, @StateObject, @EnvironmentObject.
- NavigationStack, NavigationLink, sheet, alert.
Step 3, Concurrency
- async/await with networking and disk access.
- Task and .task modifier in SwiftUI.
- Task cancellation. Use try Task.checkCancellation() in long tasks.
Step 4, Architecture
- MVVM with SwiftUI: simple ViewModel classes, ObservableObject, @Published.
- Keep networking and storage out of Views.
- Protocols for services if you want testing.
- Where UIKit still fits
- Complex custom drawing with CALayer or Core Animation.
- Old codebases, UIKit-based navigation with some SwiftUI screens.
- Third party SDKs that expose UIViewController and UIView.
You bridge:
- UIKit inside SwiftUI:
struct LegacyControllerView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) → SomeUIKitController {
SomeUIKitController()
}
func updateUIViewController(_ uiViewController: SomeUIKitController, context: Context) {}
}
- SwiftUI inside UIKit:
let vc = UIHostingController(rootView: MySwiftUIView())
- Concrete learning path for you
Week 1
- Update Xcode.
- Build 2 small apps in SwiftUI only. One counter, one simple list with detail.
- Use only @State, @StateObject, @ObservedObject.
Week 2
- Add async networking with async/await to one app.
- Parse JSON with Codable.
- Use .task to load data.
Week 3
- Add a view model layer with ObservableObject.
- Add basic persistence with UserDefaults or simple file storage async.
Week 4
- Pick one UIKit app you wrote before.
- Replace one screen with SwiftUI via UIHostingController.
- Maybe add a SwiftUI-only settings screen.
- Minimal useful resources
- Apple’s “Scrumdinger” sample project for SwiftUI + async/await.
- Hacking with Swift “100 Days of SwiftUI”, skip what you already know.
- WWDC videos: “Meet async/await in Swift” and “Modern Concurrency in Swift”.
If you share what kind of apps you want to build now, people can suggest a more exact stack, like “SwiftUI + async/await only” or “keep UIKit and add SwiftUI views on top”.
You’re not crazy, the stack really did reshuffle while you were gone.
I agree with a lot of what @sterrenkijker said, but I’d tilt the emphasis a bit:
- SwiftUI vs UIKit in practice
UIKit is still the tool you reach for when:
- You need pixel-perfect, weird custom interactions.
- You rely heavily on existing UIKit-heavy SDKs.
- You want super mature behavior around things like text input, keyboard management, etc.
SwiftUI is great, but on iOS 17 it still has rough edges for:
- Complex collection layouts that would be trivial with UICollectionViewCompositionalLayout.
- Very custom navigation flows.
So instead of “SwiftUI first always”, I usually tell people: - Greenfield consumer apps: mostly SwiftUI, keep UIKit ready for the tricky 10%.
- Business / enterprise apps: UIKit is still a perfectly valid starting point, then embed SwiftUI where it makes sense.
- Combine vs async/await reality check
I’d almost say: if you’re coming back now and don’t have legacy Combine code, you can ignore Combine at first.
Reasons:
- async/await plus
AsyncSequencealready covers a lot of the “stream of values over time” story. - The mental model of Combine is heavier than its actual benefit for many apps.
Concrete approach:
- Start with pure async/await and completion handlers.
- Only touch Combine when you hit something like:
- You must integrate with an existing Combine API (e.g. some frameworks).
- You need fancy composition of multiple long-lived streams.
- Concurrency: what actually bites people
The part folks underestimate is notasyncfunctions themselves, but data ownership. Stuff that causes pain:
- Mutating shared state from background tasks.
- ViewModels doing work off the main thread and randomly updating
@Publishedproperties.
So I’d focus on 3 rules:
- Mark any ViewModel that talks to SwiftUI as
@MainActor. That alone prevents a ton of subtle crashes. - Use
actorfor shared mutable stores instead of clever locks. - Treat
Task {}like “launching a child thread”: if you start one, know when and how it cancels, or you will create zombie work in your app.
- Architecture: don’t over-MVVM yourself
A lot of modern tutorials push really heavy MVVM, protocol layers, dependency injection, etc. Honestly, most small and medium apps don’t need that from day one.
A practical progression:
- Start with “fat” but reasonable ViewModels and a small services layer.
- Only abstract with protocols when you actually need multiple implementations (like real API vs mock).
- Keep SwiftUI views as dumb as possible: pass in plain values and simple bindings, keep logic in ViewModels.
- Concrete “map” from old knowledge to new stuff
Your old UIKit patterns map roughly like this:
-
UIViewControllerlifecycle
→ SwiftUIViewbody is just a function of state, so lifecycle is more about when state changes and when tasks start. Use:.taskmodifier for on-appear async work.onDisappearfor cleanup if really needed
-
Auto Layout constraints
→ HStacks, VStacks, ZStacks, spacers, and.frameconstraints. For complex layouts:- Sometimes it’s easier to embed a UIKit view with Auto Layout inside SwiftUI using
UIViewRepresentablethan forcing SwiftUI to do awkward layouts.
- Sometimes it’s easier to embed a UIKit view with Auto Layout inside SwiftUI using
-
Target/action and delegates
→ Combine / async callbacks in ViewModel, exposed through@Publishedand simple methods.
→ For really simple flows, just mutate@Statein the View itself.
- Rough learning plan that avoids overload
Instead of a week by week plan, I’d slice by features:
Step A: SwiftUI-only, no async
- Build 2 screens: a form and a list with navigation.
- Practice these only:
@State,@Binding, simpleNavigationStack,sheet.
Goal: comfortably build UI without thinking about “what replaces viewDidLoad”.
Step B: Add async/await networking
- Wrap one network call in
async throws. - Call it from a
@MainActorViewModel with aload()method. - Trigger
load()from.taskin the SwiftUI View.
Goal: one clean flow from “View appears” to “data displayed or error shown”.
Step C: Integrate something UIKit-ish
- Either a custom text field behavior or a map / camera view.
- Bridge it into SwiftUI using
UIViewRepresentable.
Goal: convince yourself you’re not trapped if SwiftUI can’t do something cleanly.
- Stuff you can safely ignore at first
- Advanced Combine operators (
flatMap,switchToLatest, etc). - Heavy DI frameworks and complex coordinators.
- Result builders beyond SwiftUI itself.
- Exotic navigation architectures.
If you can:
- Build a list/detail SwiftUI app
- Fetch data with async/await
- Manage state with
@StateObject+ObservableObject - Toss in one bridged UIKit component
…you’re already “modern iOS dev compatible”. Everything else is refinement, not entry ticket.
What kind of apps were you building before, and what do you want to ship now? That choice honestly decides how aggressive you should be with SwiftUI vs sticking to UIKit.
You already got solid technical direction from @nachtdromer and @sterrenkijker, so I’ll focus on how to think about the new stack rather than more code snippets.
1. Don’t treat “modern iOS” as a full reset
A mild disagreement with both: you don’t actually have to decide “SwiftUI vs UIKit” upfront. The real split today is:
- State driven UI vs event driven UI
- Structured concurrency vs callback soup
If you bring your UIKit habits (mutable state scattered everywhere, tight coupling to controllers) into SwiftUI, it will feel worse than UIKit. So before UI choices, update your mental model:
- Single source of truth per screen
- Immutable value types flowing down
- Clear boundaries where side effects happen (network, storage, analytics)
If you keep that in mind, you can hop between UIKit + SwiftUI without friction.
2. SwiftUI, Combine, async/await: what actually sticks long term
Where I slightly part ways with the “ignore Combine” advice:
- async/await is the new baseline, no argument.
- Combine is not mandatory, but learning the concept of streams is still useful, because:
- Many Apple APIs and 3rd party SDKs will keep exposing publishers.
- Even if you use
AsyncSequencelater, the thinking pattern is the same: values over time, not single results.
Practical plan:
- Start with
async/awaitonly. - When you see something like
Publisherin an API, do the minimum to map it into async (valueson a publisher in newer SDKs) instead of learning all of Combine at once.
3. Architectural stance: think “boundaries,” not MVVM purity
Big trap for returners: over-architecting because “modern.”
Instead of obsessing over MVVM correctness:
- Make Views extremely dumb:
- Read-only data
- Tiny bindings for user interaction
- Put all side effects into use case / service functions:
loadUser(),saveSettings(),search(query:), etc
- Let ViewModels just orchestrate:
- Call services
- Expose simple published properties
If you ever switch from SwiftUI to UIKit on a screen, those services and view models stay the same. That’s what actually future proofs you, not whether you have the perfect Coordinator pattern.
4. Where I’d go stricter than the other replies
Two things I’d be more opinionated about:
-
Avoid mixing navigation systems per feature.
If a flow starts with SwiftUINavigationStack, keep the whole flow SwiftUI, even if one screen is a wrapped UIKit view. Cross-cutting UIKit navigation and SwiftUI navigation in the same user journey gets messy fast. -
Be ruthless with background work.
- Mark anything that updates view state as
@MainActor. - For work that can be cancelled by navigation away, always use
Task {}from the view, not detached tasks. - Explicitly think “what happens if the user backs out mid-request” when writing async code.
- Mark anything that updates view state as
5. Concrete “upgrade” of your old UIKit knowledge
Take one of your older apps and do this minimal modernization without rewriting everything:
- Keep the navigation and main controllers in UIKit.
- Extract any networking layer that uses completion handlers into
asyncfunctions. - Wrap one new feature screen in SwiftUI using
UIHostingController. - Keep data flow one way:
- Root UIKit controller owns the model
- Pass that down into SwiftUI
- Let SwiftUI call closures to trigger actions upward
This gets you used to the modern stack while leveraging what you already know, instead of tossing everything to chase “fully SwiftUI.”
6. About the product title “”
Since you mentioned tooling and best practices in general, a couple of quick points framed as pros/cons for “” in the ecosystem:
Pros:
- Can be integrated cleanly into a SwiftUI + async/await architecture.
- Plays well with a layered approach where services and view models are testable.
- Flexible enough to live alongside UIKit components, so you are not locked into one UI framework.
Cons:
- If it assumes heavy Combine usage out of the box, there can be a steeper learning curve when you are just getting comfortable with async/await.
- Some older materials or examples for “” might be biased toward UIKit-era patterns, so you may have to mentally translate them to SwiftUI-first patterns.
In practice, you can absolutely use “” in a project that follows the staged learning path that @nachtdromer described and the pragmatic UIKit/SwiftUI mix that @sterrenkijker outlined.
7. How to actually move forward without getting overwhelmed
A compact decision guide for every new feature you build:
-
UI:
- Is it “standard app UI” (lists, forms, simple flows)? Start in SwiftUI.
- Is it highly custom or animation heavy? Consider UIKit, wrapped into SwiftUI if the rest of your app is SwiftUI.
-
Async work:
- Single result: async/await.
- Repeating / streaming: minimal Combine or
AsyncSequenceif available.
-
State:
- Per-view scratch state:
@State. - Feature-level state:
@StateObjectViewModel marked@MainActor. - Shared app-wide state: a dedicated store type (class or actor) and inject it, don’t scatter globals.
- Per-view scratch state:
If every new feature you write passes those three checkpoints, you are effectively doing “modern iOS” even if half your app is still UIKit.
If you share a rough description of one of your old apps (say “master-detail with offline sync” or “form-based business app”), you can get a very specific migration sketch for that scenario.