What are proven approaches for structuring the networking layer of a SwiftUI app? Specifically, how do you structure using URLSession to load JSON data to be displayed in SwiftUI Views and handling all the different states that can occur properly?
2 Answers
Here is what I came up with in my last projects:
- Represent the loading process as a ObservableObject model class
- Use URLSession.dataTaskPublisher for loading
- Using Codable and JSONDecoder to decode the response to Swift types using the Combine support for decoding
- Keep track of the state in the model as a @Published property so that the view can show loading/error states.
- Keep track of the loaded results as a @Published property in a separate property for easy usage in SwiftUI (you could also use
View#onReceive
to subscribe to the publisher directly in SwiftUI but keeping the publisher encapsulated in the model class seemed more clean overall) - Use the SwiftUI
.onAppear
modifier to trigger the loading if not loaded yet. - Using the
.overlay
modifier is convenient to show a Progress/Error view depending on the state - Extract reusable components for repeatedly occuring tasks (here is an example: EndpointModel)
Standalone example code for that approach (also available in my SwiftUIPlayground):
// SwiftUIPlayground
// https://github.com/ralfebert/SwiftUIPlayground/
import Combine
import SwiftUI
struct TypiTodo: Codable, Identifiable {
var id: Int
var title: String
}
class TodosModel: ObservableObject {
@Published var todos = [TypiTodo]()
@Published var state = State.ready
enum State {
case ready
case loading(Cancellable)
case loaded
case error(Error)
}
let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
let urlSession = URLSession.shared
var dataTask: AnyPublisher<[TypiTodo], Error> {
self.urlSession
.dataTaskPublisher(for: self.url)
.map { $0.data }
.decode(type: [TypiTodo].self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
func load() {
assert(Thread.isMainThread)
self.state = .loading(self.dataTask.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case let .failure(error):
self.state = .error(error)
}
},
receiveValue: { value in
self.state = .loaded
self.todos = value
}
))
}
func loadIfNeeded() {
assert(Thread.isMainThread)
guard case .ready = self.state else { return }
self.load()
}
}
struct TodosURLSessionExampleView: View {
@ObservedObject var model = TodosModel()
var body: some View {
List(model.todos) { todo in
Text(todo.title)
}
.overlay(StatusOverlay(model: model))
.onAppear { self.model.loadIfNeeded() }
}
}
struct StatusOverlay: View {
@ObservedObject var model: TodosModel
var body: some View {
switch model.state {
case .ready:
return AnyView(EmptyView())
case .loading:
return AnyView(ActivityIndicatorView(isAnimating: .constant(true), style: .large))
case .loaded:
return AnyView(EmptyView())
case let .error(error):
return AnyView(
VStack(spacing: 10) {
Text(error.localizedDescription)
.frame(maxWidth: 300)
Button("Retry") {
self.model.load()
}
}
.padding()
.background(Color.yellow)
)
}
}
}
struct TodosURLSessionExampleView_Previews: PreviewProvider {
static var previews: some View {
Group {
TodosURLSessionExampleView(model: TodosModel())
TodosURLSessionExampleView(model: self.exampleLoadedModel)
TodosURLSessionExampleView(model: self.exampleLoadingModel)
TodosURLSessionExampleView(model: self.exampleErrorModel)
}
}
static var exampleLoadedModel: TodosModel {
let todosModel = TodosModel()
todosModel.todos = [TypiTodo(id: 1, title: "Drink water"), TypiTodo(id: 2, title: "Enjoy the sun")]
todosModel.state = .loaded
return todosModel
}
static var exampleLoadingModel: TodosModel {
let todosModel = TodosModel()
todosModel.state = .loading(ExampleCancellable())
return todosModel
}
static var exampleErrorModel: TodosModel {
let todosModel = TodosModel()
todosModel.state = .error(ExampleError.exampleError)
return todosModel
}
enum ExampleError: Error {
case exampleError
}
struct ExampleCancellable: Cancellable {
func cancel() {}
}
}

- 3,556
- 3
- 29
- 43
-
1This is a great example but fits only single/simple use case of working with 1 model. How do you go about refactoring this code so that the Loader/StatusOverlay components are generic over multiple Codable structs? – Paul D. Jul 13 '20 at 12:04
Splitting off the state / data / networking into a separate @ObservableObject class outside the View Struct is definitely the way to go. There are too many SwiftUI "Hello World" examples out there stuffing it all into the View struct.
As a best practice you could look to standardize your @ObservableObject naming inline with MVVM and call that "Model" class a ViewModel, as in:
@StateObject var viewModel = TodosViewModel()
The majority of code in there is handling overlay state, onAppear events and display issues for the View.
Create a new TodosModel class and reference that in the ViewModel:
@ObservedObject var model = TodosModel()
Then move all the networking / api / JSON code into that class with one method called by ViewModel:
public func getList() -> AnyPublisher<[TypiTodo], Error>
The View-ViewModel-Model are now split up, related to Paul D's comment, the ViewModel could combine 1 or more Models to return whatever the view needs. And, more importantly, the TodoModel entity knows nothing about the View and can focus on http / JSON / CRUD.
Below is great example using Combine / HTTP / JSON decode. You can see how it uses tryMap, mapError to further separate the networking from the decode errors. https://gist.github.com/stinger/e8b706ab846a098783d68e5c3a4f0ea5
See a very short and clear explanation of the difference between @StateObject and @ObservedObject in this article: https://levelup.gitconnected.com/state-vs-stateobject-vs-observedobject-vs-environmentobject-in-swiftui-81e2913d63f9

- 287
- 1
- 4