SwiftUI in an Async World
— SwiftUI, DataSource, Swift Concurrency — 9 min read
At TheLeftBit we've built great UIKit apps that have stood the test of time and scaled beautifully by following three basic rules:
- Keep it simple
- Pass values to functions
- Don't fight the platform
Now, as we transition to SwiftUI, we're formalizing how things are structured in order for our developers to tackle creating beautiful user interfaces without too much fuzz. We wanted the code to adopt modern Swift concepts like Structured Concurrency, Result Builders and Generics in order to be even more efficient than what we where when building UIs with UIKit.
We quickly came to the realization that in most modern apps, most of the Data and State is remote. It's behind some kind of HTTP call. And this fact should get first-citizen support in whatever patterns we chose.
On the other hand, SwiftUI expects data to be stored and passed around the Views using property wrappers like @State
, @Binding
or @Published
. To simplify this, one of the easiest ways is to store all the required properties in some "Data Model" object that stores all the properties using @Published
(not necessary if we were to target iOS 17 thanks to Observable
, but the gist is the same) and then pass that object into the View
either using simple injection in the initializer or an .environmentObject()
call.
Simple Book List:
Let's keep in mind these two things, and build a simple SwiftUI view that shows a list of books that comes from a remote REST API.
The Book
model looks like:
1struct Book: Identifiable, Decodable {2 let id: UUID3 let title: String4 let author: String5}
After that, we need some object to hold all the books for the View
. We are calling it DataModel
in this example, but could be anything the team decides on. Let's create an ObservableObject
for this:
1class DataModel: ObservableObject { 2 @Published var isLoading: Bool3 @Published var error: Swift.Error?4 @Published var books: [Book]5 init(isLoading: Bool = false, error: Error? = nil, books: [BookList.Book] = []) { ... }6}
Now, let's display this in a list:
1struct BookList: View {2 3 @ObservedObject var dataModel: DataModel4 5 var body: some View {6 if dataModel.isLoading {7 ProgressView()8 } else let error = dataModel.error {9 ErrorView(error)10 } else {11 List(dataModel.books, id: \.id) { book in12 VStack {13 Text(book.title)14 .foregroundStyle(.primary)15 Text(book.author)16 .foregroundStyle(.secondary)17 }18 }19 }20 }21}
This all compiles and similar code has been written in countless blog posts all over the internet. But a question come to mind:
- How do we populate that data?
We could add to DataModel
an async function to populate the data:
1extension DataModel {2 func populateData() async {3 self.isLoading = true4 let request: URLRequest = { ... }5 do {6 let response = try await URLSession.shared.data(for: request).07 let books = try JSONDecoder().decode([Book].self, from: response)8 self.books = books 9 } catch {10 self.error = error11 }12 self.isLoading = false13 }14}
There's a lot of book-keeping going on. We have to make sure we're setting the correct loading state, capturing the error and storing the response in the @Published
property. Doable, but still, definitely improvable.
And now we have to figure out a place to call this from the View to make sure it's populated. Thankfully, since iOS 15 we've had the .task()
modifier, which allows us to run async
functions when the view is about to be rendered.
1struct BookList: View {2 var body: some View {3 { ... }4 .task {5 await dataModel.populateData()6 }7 }8}
This works, and most people leave it like this and continue with their lives.
But there are several code smells here:
- The
if {} else if {} else {}
branch in the view's body will sure become more and more complex as we add features to this view.- Imagine a new requirement to only show a special upsell banner in case the user doesn't have a subscription. Cyclomatic complexity is always something we have to be aware of and try to reduce.
- If we try to Preview this
View
, it'll make a network call to populate it's data. This is not something you want to do for your production apps, since there might be Authentication required, but also, it's definetly not the correct thing to do, since it makes your Previews unpredictable, and Previews should be treated as Unit Tests: they should succeed regardless of the network status of your machine.
So how do we address these issues?
Async Initialization
The first breakthrough in the way we architecture SwiftUI apps was that we could add two initializers to our DataModel
objects:
- An async initalizer for when the view is run regularly.
- A sync initializer when the view is Previewed.
To continue with the previous example, we can now remove those pesky var isLoading: Bool
and var error: Swift.Error?
properties, the populateData
function and do:
1extension DataModel {2 3 init(restAPIClient: RestAPIClient) async throws {4 self.books = try await restAPIClient.fetchBookList()5 }6 7 init(books: [Book]) {8 self.books = books9 }10}
In the above example, let's assume that RestAPIClient
is an object that will perform all the URLSession
and JSON parsing work and return a parsed array of Book
in case the request is sent and parsed succesfully.
We can now, from a Preview, create a DataModel
with mock Books and inject it in the View, allowing us to work the the View's layout without launching the App or sending network requests to servers.
Like we say here at TheLeftBit, "if Previews don't work, it's not SwiftUI!"
And, by removing the loading
and error
properties, plus annotating the initializer as async throws
we've moved the error and async handling a level up. This means that now DataModel
is not directly usable from BookListView
, since we can't set the @ObservedObject
of a view asynchronously. Which brings us to our second breakthrough:
Async View
As we mentioned earlier, we need some async context to create the DataModel
before passing it to the View
's initializer. And the best way to create this async context is using the .task
view modifier, but for that we need to create a View
.
Let's wrap this async work in a View
: something like this would work:
1struct BookListAsync: View {2 3 let restAPIClient: RestAPIClient4 @State var loadingPhase = LoadingPhase.loading5 6 enum LoadingPhase {7 case loading8 case loaded(DataModel)9 case failed(Swift.Error)10 }11 12 var body: some View {13 contentView14 .task {15 self.loadingPhase = .loading16 do {17 let dataModel = try await DataModel(restAPIClient: restAPIClient)18 self.loadingPhase = .loaded(dataModel)19 } catch {20 self.loadingPhase = .failed(error)21 }22 }23 }24 25 @ViewBuilder26 private var contentView: some View { 27 switch loadingPhase {28 case .loading: 29 ProgressView()30 case .failed(let error): 31 Text(error.localizedDescription)32 case .loaded(let dataModel):33 BookList(dataModel: dataModel)34 }35 }36}
This is much better: we are using the LoadingPhase
enum to model the what to show in the View: either a spinner, a message with the error or the actual BookList
. And, we are leveraging the .task
modifier to perform the actual creation of the DataModel
.
This comes with a lot of advantages:
- The
BookList
is only created when there is actual data to show. It made no semantical sense for it to be holding an empty array, much better to express that that Array is in a remote location by making the only way to get to itasync
. - Once the
BookList
is loaded and on-screen, any error that occurs after that (for example, marking aBook
as read) is handled on a completely different context from the loading error; reducing the overall complexity of the system. - Since we are encapsulating the Async work in another
View
, we'll be leveraging SwiftUI to cancel the Task in case the user is not interested anymore. For example, the user will dismiss this view using the back button of a NavigationStack, or swiping down a modal sheet. This will free resources and make sure the app is always performant.
This is already a lot better, but this BookListAsync
is tightly coupled to BookList
and it's DataModel
, making it impossible to reuse it with another view. We have two options to make this view reusable:
- Use Swift Macros to auto-generate the code
- Use Generics to inject the View.
We discarted using Swift Macros since we were having trouble making the inputs of the view more flexible.
The final component we built, using Swift Generics and applied to the BookListAsync
example, has the following API:
1var body: some View {2 AsyncView(3 id: $viewID4 dataGenerator: {5 try await DataModel(restAPIClient: restAPIClient)6 },7 hostedViewGenerator: { dataModel in8 BookList(dataModel: dataModel)9 },10 loadingViewGenerator: {11 ProgressView()12 },13 errorViewGenerator: { error, onRetry in14 Text(error.localizedDescription)15 }16 )17}
Where the parameters are:
id
: is the identifier passed to the.task(id:priority:_:)
View Modifier. Having this parameter will allow us to reload the view if an upstream value changes. Think for example, a screen where the there are some sort of filter UI that triggers a new HTTP request to reload the contents of a list. This becomes trivial now.- If the view will not be reloaded as a result of user input, you can safely pass a
.constant()
value here with a hardcoded string.
- If the view will not be reloaded as a result of user input, you can safely pass a
dataGenerator
: this closure will generate the data required by the view to render it's contents. If an error is thrown in this closure, the Error View will be shown.hostedViewGenerator
: this closure will generate the view once the data it needs is available.loadingViewGenerator
: this closure will generate the view that will be shown during thedataGenerator
execution.errorViewGenerator
: this closure will generate the view that will be shown when an error is thrown.- The
error
is passed in order to make sure that the view can show an appropiate message. Also, anonRetry
closure is passed to make sure the user can retry the operation.
- The
The implementation itself is not as interesting, you can check it out here. Having this View allows us to architect our code in a much better way:
1struct BookList: View {2 @ObservedObject var dataModel: DataModel3 var body: some View { ... }4 5 struct Async: View {6 let restAPIClient: RestAPIClient7 var body: some View {8 AsyncView(...)9 }10 }11}
This makes sure that when using autocomplete, the developer won't find a way to create a BookList
without a DataModel
, and nudge him towards BookList.Async
.

Note: How the RestAPIClient
instance is injected in the BookList.Async
view is up to you; we're using simple value injection, but this could very well be an @EnvironmentObject
or anything else you and your team are using for Dependency Injection.
Conclusions:
These two breakthroughs have allowed us to iterate quickly, making sure everyday tasks are handled by this abstractions and allowing us to focus on what sets up each project apart.
All of this, by still leveraging the same three concepts:
-
Keep it simple:
- A simple API and a simple abstraction allows developers of all seniority levels to use it.
-
Pass values to functions:
- How the dependencies are passed to the
Async
view is up to the developer, but it's always evident what's happening.
- How the dependencies are passed to the
-
Don't fight the platform:
- Leverage the platform (in this case SwiftUI) and it's primitives and data-flows, allowing our code to mature along the platform.
Thanks for reading and contact us in case you have any questions!