MVVM (Model-View-ViewModel) is a UI design pattern created to help us separate logic from our views, in turn this helps us write more succinct, maintainable modules for our apps.
We're going to create a simple app that pulls down a list of Pokemon from https://pokeapi.co/ and display them in a table, simple enough, but i'll show you how to use combine to make network requests, parse the incoming data and render that in a SwiftUI List
.
By the end of this post you should be comfortable in the following.
- Create a ViewModel for you SwiftUI views
- Use data binding to communicate model changes to SwiftUI views
- Use Combine to make network requests and handle responses
The complete project for this article can be found on github
Open up Xcode and create a new project, select Empty View and make sure SwiftUI is selected in the dropdown.

You'll be presented with your default ContentView
, let's quickly change the name of this from ContentView
to PokemonListView
. Right click (or cmd click) on the ContentView
declaration and click Refactor->Rename, change this to PokemonListView
, this will change the name in the file, the filename and the reference in SceneDelegate.swift
. The only thing left to do is rename ContentView_Previews
at the bottom of the PokemonListView
file to PokemonListView_Previews
and replace the ContentView
inside with PokemonListView
.
MVVM Explained
As stated earlier, MVVM stands for Model-View-View Model, and is a UI design pattern geared towards the separation of concerns between our models and views.

As you can see in the above diagram, the ViewModel is responsible for communication between the view and the model, there are many flavours or ViewModel, but the most common scenario uses data bindings to pass updates from the ViewModel to the View. You could use something like Key Value Observation, but this task is made much easier using a framework like combine.
API Layer
We're going to start off defining our networking module, open a new blank Swift file called APIService
and add the following code.
import Foundation
import Combine
protocol APIService {
func request<T: Decodable>(with builder: RequestBuilder) -> AnyPublisher<T, APIError>
}
This is fairly simple, a protocol defining a single function, it accepts a RequestBuilder
as it's only parameter which we've yet to define, returns a Publisher of type AnyPublisher
with our generic type T
and APIError
which we also need to define. Open a new file called RequestBuilder
and add the following.
protocol RequestBuilder {
var urlRequest: URLRequest {get}
}
Another simple protocol that requires the conforming type to provide a URLRequest
instance.
Lastly we need to define APIError
, an enum conforming to swift's Error
protocol. Open a new file called APIError
and add.
enum APIError: Error {
case decodingError
case httpError(Int)
case unknown
}
We define three error cases here that we'll use later when dealing with the network request. These are all the protocols we need to define next we're going to walk through the implementation.
At this point you may be asking youself what AnyPublisher
is, and why is it returned by our APIService. In Combine we have the concept of Subscribers and Publishers; a subscriber listens (or subscribes) to events from a publisher, and in turn a publisher hands off events (publishes) to it's subscribers. This is the foundation on which Combine - and FRP - is built, some other FRP frameworks use different names but the fundamentals are the same. There are many types of publishers apple provides us but we don't necessarily care about the type of publisher we used, we just car about the values, this is where AnyPublisher
comes in to play, it uses type erasure to hide implementation details from our API and so we can just deal with the things we care about. If any of this is confusing don't worry, it will hopefully become clearer as we move on.
Create a new file called APISession
, this will be responsible for implementing our APIService
and performing network operations. If. you're not familiar with combine the implementation may look a little daunting i'll step through it line-by-line.
import Combine
struct APISession: APIService {
func request<T>(with builder: RequestBuilder) -> AnyPublisher<T, APIError> where T: Decodable {
// 1
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
// 2
return URLSession.shared
.dataTaskPublisher(for: builder.urlRequest)
// 3
.receive(on: DispatchQueue.main)
// 4
.mapError { _ in .unknown }
// 5
.flatMap { data, response -> AnyPublisher<T, APIError> in
if let response = response as? HTTPURLResponse {
if (200...299).contains(response.statusCode) {
// 6
return Just(data)
.decode(type: T.self, decoder: decoder)
.mapError {_ in .decodingError}
.eraseToAnyPublisher()
} else {
// 7
return Fail(error: APIError.httpError(response.statusCode))
.eraseToAnyPublisher()
}
}
return Fail(error: APIError.unknown)
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
The above syntax isn't as scary as it looks, each one of these lines is a function that returns a publisher which is passed down to the next function, which is passed down to the next and so on...until we reach the end of the chain. We then use .eraseToAnyPublisher
to type-erase the returned publisher to AnyPublisher
. If we don't do this we end up with an incomprehensible chain of wrapped publishers. With that in mind let's step through the code.
- Create an instance of
JSONDecoder
to decode our incoming data. We can use the built inconvertFromSnakeCase
keyDecodingStrategy
to convert the incoming snake case to camel case. - This is one of those built-in publishers that I mentioned earlier, it takes a
URLRequest
as a parameter - which we provide via ourRequestBuilder
instance - performs a network request and returns a publisher of typeURLSession.DataTaskPublisher
. - This operaor specifies the scheduler we'd like to receive the data on, in this case we want to receive our data on the main thread.
- This is an interesting one, if an error occurs
mapError
will provide an error of typeError
that we need to convert - or 'map' - into a type ofAPIError
, after all that's what we've told our publisher to expect in the return type of this function. If something fails at this point we return the typeAPIError.unknown
. - You may be more familiar with this code;
flatMap
allows us to transform the incoming data from ourURLSession.dataTaskPublisher
into a new publisher who's type matches the types we expect. - Here's where we do the transformation mentioned above, the
Just
publisher emits a single value, we pass in our data here which is then handed off todecode
which transforms it to our typeT
(which is of type Decodable), we then usemapError
which returnsdecodingError
should something go wrong with the decoding, and finallyeraseToAnyPublisher
to type erase our complex publisher into something more manageable. - We use the
Fail
publisher here and return.httpError
with the provided http status code from our response.Fail
finishes immediately and provides an error of the type we specified in the return type. For example, if we tried to useFail
with an instance ofNSError
here we'd get a compile time error, as our function expects to return an error of typeAPIError
.
That was a lot to unpack! Combine seems complex when starting out, but I promise it becomes elegantly simple once you're used to dealing with all the modifiers. If you want to dig deeper i'd recommend reading the book Practical Combine by Donny Walls, it's a fantastic book that goes into far more depth on Combine and it's many features.
Integrating the API layer
Now we have a fully functional API layer written in combine - Congratulations 🎉. But that's just half of the equation, next we need to define the modules that integrate with it and provide data back to our view model.
Add a new file called PokemonEndpoint
and add the following.
enum PokemonEndpoint {
case pokemonList
}
extension PokemonEndpoint: RequestBuilder {
var urlRequest: URLRequest {
switch self {
case .pokemonList:
guard let url = URL(string: "https://pokeapi.co/api/v2/pokemon")
else {preconditionFailure("Invalid URL format")}
let request = URLRequest(url: url)
return request
}
}
}
The PokemonEndpoint
enum will define all the possible endpoints we care about for the pokemon API, the extension below adds conformance to our RequestBuilder
who's only requirement is an instance of URLRequest
. We define urlRequest
as a computed property, inside of which we use a switch
statement to return a URLRequest
instance configured with a URL.
Add another file and call it PokemonService
and add the following.
import Foundation
import Combine
protocol PokemonService {
var apiSession: APIService {get}
func getPokemonList() -> AnyPublisher<PokemonListAPIResponse, APIError>
}
extension PokemonService {
func getPokemonList() -> AnyPublisher<PokemonListAPIResponse, APIError> {
return apiSession.request(with: PokemonEndpoint.pokemonList)
.eraseToAnyPublisher()
}
}
Here we define a single parameter of type APIService
and a single function getPokemonList
that returns a type of AnyPublisher
, but this time we provide a new type - PokemonListAPIResponse
- that we're yet to define, and our APIError
as the potential error type as normal. We then provide implementation details for the function inside an extension, we use our apiSession
variable and pass in the pokemonList
endpoint we defined earlier.
Let's fix that build error and add our PokemonListAPIResponse
. Create a new file and add the following.
import Foundation
struct PokemonListAPIResponse: Codable {
let count: Int
let next: String
let previous: String?
let results: [PokemonListItem]
}
Finally add one more file called PokemonListItem
and add.
import Foundation
struct PokemonListItem: Codable, Identifiable {
let id = UUID()
let name: String
let url: String
}
Thanks for sticking with me this long, we're nearly there. The final step brings all of these pieces together in the view model, that's why you're here after all!
The ViewModel
The ViewModel is where everything comes together, we're going to use the API layer we just build to trigger a network request, perform some operations using combine and publish the results to SwiftUI
, let's get going...
Create a new file called PokemonListViewModel
and add the following code.
import Foundation
import Combine
import SwiftUI
class PokemonListViewModel: ObservableObject, PokemonService {
var apiSession: APIService
@Published var pokemon = [PokemonListItem]()
var cancellables = Set<AnyCancellable>()
init(apiSession: APIService = APISession()) {
self.apiSession = apiSession
}
func getPokemonList() {
let cancellable = self.getPokemonList()
.sink(receiveCompletion: { result in
switch result {
case .failure(let error):
print("Handle error: \(error)")
case .finished:
break
}
}) { (pokemon) in
self.pokemon = pokemon.results
}
cancellables.insert(cancellable)
}
}
Starting at the top, PokeonListViewModel
conforms to ObservableObject
so SwiftUI
can use it to listen for updates, there really are no requirements for this conformance other than the type be a class. We also conform to PokemonService
who's only requirement is that we provide an APIService
instance.
Providing dependencies in this way makes our modules much easier to test, for example if I wanted to swap out the implementation of APIService
here, I could and the view model wouldn't need to know. I have an active project i'm working on now that uses this method and loads JSON from a file instead of an API.
Next we have the pokemon
variable of type [PokemonListItem]
, this will store the results of our API request. Notice the @Published
property wrapper here, this tells SwiftUI that we should inform any subscribers listening when this property changes.
In this example our view model is the subscriber (remember we have both publishers and subscribers); when we subscribe to a publisher we get an instance of AnyCancellable
back, this allows us to cancel the subscribtion when we're done with it. We need to store a reference to all of our subscriptions to keep them alive and cancel them when we're done. The cancellables
set is how we manage this, we'll keep a record of all our subscriptions until the class's deinit
is called, at which time they'll be cancelled.
Finally we have the getPokemonList
function, this performs our network request and handles the result. One way to subscribe to a publisher in combine is using the sink
function, this takes two closures receiveCompletion
, receiveValue
and returns the type AnyCancellable
that you were introduced to earlier. You can think of sink
as the place where all our values end up, and think of the publisher as the taps. receiveCompletion
is called when the publisher has finished sending values, this could be because all values were sent or becuase of an error. receiveValue
is called whenever the subscriber receives a value from the publisher, it's in here that we update our list of pokemon.
Bringing it all together
Back in PokemonListView
, replace the contents with the following.
struct PokemonListView: View {
@ObservedObject var viewModel = PokemonListViewModel()
var body: some View {
NavigationView {
List(self.viewModel.pokemon) { pokemon in
Text(pokemon.name.capitalized)
}
.navigationBarTitle("Pokemon")
}
.onAppear {
self.viewModel.getPokemonList()
}
}
}
We create an instance of PokemonListViewModel
at the top and use the @ObservedObject
property wrapper to listen for updates, this tells SwiftUI to monitor our view model for updates and redraw the UI when we receive any. When the onAppear
event happens (think of this as viewDidAppear
when using a UIViewController
) we call getPokemonList
on the viewModel
, which triggers the API call and our subscriber chain. We then use a standard List
to loop over the pokemon and display the names in a list, the API will send back 20 Pokemon at a time.
Combine is a completely new way of developing apps, don't be put off if any of this is confusing to you, covering the basics of combine was out of scope for this article but there's a lot of content out there that should help get you up to speed. Hopefully this introduction to combine with MVVM and SwiftUI was useful, it's easy to see how these two frameworks complement each other so well and are powerful tools in your swift toolkit.
This article covered the following.
- Setup SwiftUI views using MVVM and data bindings
- Make network requests using URLSession's DataTaskPublisher
- Create a publisher chain and manipulate incoming data streams
- Subscribe to Publishers and handle incoming data streams
The complete project for this article can be found on github
If you found this useful, please feel free to reach out on Twitter!
Thanks for reading ✌️