In the previous episode, you learned about futures and promises. In this episode, I show you how to use them in a project.
Replacing Completion Handlers with Futures
Fire up Xcode and open the starter project of this episode if you want to follow along. Build and run the application in a simulator to see it in action. The application fetches a collection of landscapes from a remote server and displays the landscapes in a table view. Each landscape has a title and an image.

The project adopts the Model-View-Controller pattern. Let's take a look under the hood. The LandscapesViewController class does most of the heavy lifting. It invokes a helper method, fetchLandscapes(), in its viewDidLoad() method.
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
// Fetch Landscapes
fetchLandscapes()
}
The fetchLandscapes() method fetches an array of landscapes from a remote server using the URLSession API. The landscapes view controller stores the collection in a property, landscapes, and displays the landscapes in its table view.
// MARK: - Helper Methods
private func fetchLandscapes() {
// Start/Show Activity Indicator View
activityIndicatorView.startAnimating()
let url = URL(string: "https://cdn.cocoacasts.com/api/landscapes/v1/landscapes.json")!
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let data = data else {
print("Unable to Fetch Landscapes")
return
}
do {
// Decode Response
let landscapes = try JSONDecoder().decode([Landscape].self, from: data)
// Update Data Source and
// User Interface on Main Thread
DispatchQueue.main.async {
// Update Landscapes
self?.landscapes = landscapes
// Stop/Hide Activity Indicator View
self?.activityIndicatorView.stopAnimating()
}
} catch {
print("Unable to Decode Landscapes")
}
}.resume()
}
The landscapes property is of type [Landscape] objects. Each Landscape object has a title and an image URL. The image URL points to a remote image.
private var landscapes: [Landscape] = [] {
didSet {
// Reload Table View
tableView.reloadData()
// Show/Hide Table View
tableView.isHidden = landscapes.isEmpty
}
}
In the tableView(_:cellForRowAt:) method, the view controller dequeues a table view cell, fetches the landscape that corresponds with the index path, and uses the Landscape object to populate the table view cell.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: LandscapeTableViewCell.reuseIdentifier, for: indexPath) as? LandscapeTableViewCell else {
fatalError("Unable to Dequeue Landscape Table View Cell")
}
// Fetch Landscape
let landscape = landscapes[indexPath.row]
// Configure Cell
cell.configure(title: landscape.title,
imageUrl: landscape.imageUrl,
imageService: imageService)
return cell
}
Let's take a closer look at the configure(title:imageUrl:imageService:) method of the LandscapeTableViewCell class. The table view cell asks the image service for the landscape's image by invoking its image(for:completion:) method. The method accepts a URL as its first argument and a completion handler as its second argument.
func configure(title: String, imageUrl: URL, imageService: ImageService) {
// Configure Title Label
titleLabel.text = title
// Animate Activity Indicator View
activityIndicatorView.startAnimating()
// Request Image Using Image Service
imageRequest = imageService.image(for: imageUrl) { [weak self] image in
// Update Thumbnail Image View
self?.thumbnailImageView.image = image
}
}
Open ImageService.swift and navigate to the image(for:completion:) method. The goal of this episode is to replace the completion handler with a future.
func image(for url: URL, completion: @escaping (UIImage?) -> Void) -> Cancellable {
...
}
Because we are about to work with futures, we need to add an import statement for the Combine framework at the top.
import UIKit
import Combine
final class ImageService {
...
}
Revisit the image(for:completion:) method. Remove the completion handler from the method signature and change the method's return type to AnyPublisher. The ImageService class uses a future internally, but it doesn't expose the future. The Output type of the publisher is UIImage? and its Failure type is Never.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
...
}
In the body of the image(for:) method, we create a future. Remember from the previous episode that the initializer accepts a closure as an argument. The closure that is passed to the future's initializer accepts a single argument, a promise.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
}
}
Because the image(for:) method no longer returns an object of type Cancellable, we can simplify its implementation. In the closure that we pass to the initializer of the future, we invoke the cachedImage(for:completion:) method to request a cached image, passing in the URL of the remote image and a completion handler.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
// Request Image from Cache
self.cachedImage(for: url) { image in
}
}
}
The argument of the completion handler is of type UIImage?. We use optional binding to safely unwrap the image. If a cached image is available, we invoke the future's promise, passing in the image as the associated value of the success case.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
// Request Image from Cache
self.cachedImage(for: url) { image in
if let image = image {
// Execute Promise
promise(.success(image))
} else {
}
}
}
}
In the else clause, we create a data task, passing in the URL of the remote image and a completion handler. Because the image(for:) method no longer returns an object of type Cancellable, we can simplify the implementation. First, we no longer store the data task in a constant. Second, we call resume() on the data task to initiate the request. Third, we no longer return the data task from the image(for:) method.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
// Request Image from Cache
self.cachedImage(for: url) { image in
if let image = image {
// Execute Promise
promise(.success(image))
} else {
// Request Data for Image
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
// Helper
let result: Result<Data, Error>? = {
if let data = data {
// Success
return .success(data)
} else if let error = error, (error as NSError).code != URLError.cancelled.rawValue {
// Failure
return .failure(error)
} else {
// Cancelled
return nil
}
}()
// Execute Handler on Main Thread
DispatchQueue.main.async {
switch result {
case .success(let data):
print("Data Task Succeeded")
// Execute Handler
completion(UIImage(data: data))
// Cache Image
self?.cacheImage(data, for: url)
case .failure:
print("Data Task Failed")
// Execute Handler
completion(nil)
case .none:
print("Data Task Cancelled")
break
}
}
}.resume()
}
}
}
}
The nested closures add quite a bit of complexity to the implementation. We fix that in a moment. Before we clean up the implementation, we need to invoke the future's promise in the switch statement. The changes are straightforward. We invoke the promise instead of the completion handler. We only do this in the success case because the Failure type of the publisher the image(for:) method returns is Never. In the success case, we create a UIImage instance using the Data object and pass the result as the associated value of the success case. We don't invoke the promise for the failure and none cases.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
// Request Image from Cache
self.cachedImage(for: url) { image in
if let image = image {
// Execute Promise
promise(.success(image))
} else {
// Request Data for Image
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
// Helper
let result: Result<Data, Error>? = {
if let data = data {
// Success
return .success(data)
} else if let error = error, (error as NSError).code != URLError.cancelled.rawValue {
// Failure
return .failure(error)
} else {
// Cancelled
return nil
}
}()
// Execute Handler on Main Thread
DispatchQueue.main.async {
switch result {
case .success(let data):
print("Data Task Succeeded")
// Execute Promise
promise(.success(UIImage(data: data)))
// Cache Image
self?.cacheImage(data, for: url)
case .failure:
print("Data Task Failed")
case .none:
print("Data Task Cancelled")
}
}
}.resume()
}
}
}
}
Before we clean up the implementation, we need to wrap the future in a type eraser because that is what the compiler expects. Remember that the image(for:) method doesn't return a future, it returns a publisher of type AnyPublisher.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
...
}
.eraseToAnyPublisher()
}
Earlier in this series, you learned how easy it is to force a publisher to deliver its events on the main thread, using the receive operator. Let's use the receive operator to make sure the future publishes its result on the main thread. Before we invoke the eraseToAnyPublisher() method, we invoke the receive(on:) method, passing it a reference to the main dispatch queue. This ensures the main dispatch queue is used to deliver the future's result to its subscribers.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
...
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
This means we no longer need to use Grand Central Dispatch in the completion handler of the data task.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
// Request Image from Cache
self.cachedImage(for: url) { image in
if let image = image {
// Execute Promise
promise(.success(image))
} else {
// Request Data for Image
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
...
switch result {
case .success(let data):
print("Data Task Succeeded")
// Execute Promise
promise(.success(UIImage(data: data)))
// Cache Image
self?.cacheImage(data, for: url)
case .failure:
print("Data Task Failed")
case .none:
print("Data Task Cancelled")
}
}.resume()
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
The image(for:) method no longer returns an object of type Cancellable. The caller is no longer able to cancel the image request. This is a downside, but it simplifies the implementation of the completion handler of the data task. We use a guard statement to safely access the Data object and use it to create a UIImage instance. We pass the image as the associated value of the success case to the promise. We also pass the Data object and the URL of the remote image to the cacheImage(_:for:) method to cache the result.
func image(for url: URL) -> AnyPublisher<UIImage?, Never> {
// Create Future
Future { promise in
// Request Image from Cache
self.cachedImage(for: url) { image in
if let image = image {
// Execute Promise
promise(.success(image))
} else {
// Request Data for Image
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let data = data else {
return
}
// Execute Promise
promise(.success(UIImage(data: data)))
// Cache Image
self?.cacheImage(data, for: url)
}.resume()
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
These changes drastically simplify the implementation of the image(for:) method. Before we can test the changes we made, we need to update the LandscapeTableViewCell class. Open LandscapeTableViewCell.swift and navigate to the configure(title:imageUrl:imageService:) method.
The table view cell should no longer hold on to an object of type Cancellable. We add an import statement for the Combine framework at the top and replace the imageRequest property with a property, subscription, of type AnyCancellable?.
import UIKit
import Combine
final class LandscapeTableViewCell: UITableViewCell {
...
}
private var subscription: AnyCancellable?
The image(for:) method no longer returns an object of type Cancellable. It returns a publisher instead. The table view cell attaches a subscriber to the publisher the image(for:) method returns by invoking the assign(to:on:) method. It accepts a key path as its first argument and an object as its second argument. We covered the assign(to:on:) method earlier in this series.
We pass the key path of the image property of the thumbnail image view as the first argument and the thumbnailImageView property of the table view cell as the second argument. The assign(to:on:) method returns an AnyCancellable instance. We store a reference to the AnyCancellable instance in the subscription property.
func configure(title: String, imageUrl: URL, imageService: ImageService) {
// Configure Title Label
titleLabel.text = title
// Animate Activity Indicator View
activityIndicatorView.startAnimating()
// Request Image Using Image Service
subscription = imageService.image(for: imageUrl)
.assign(to: \.image, on: thumbnailImageView)
}
In the prepare for reuse method, the table view cell sets its subscription property to nil to dispose of the subscription it references.
override func prepareForReuse() {
super.prepareForReuse()
// Reset Thumbnail Image View
thumbnailImageView.image = nil
// Terminate Subscription
subscription = nil
}
Because the image(for:) method no longer returns an object of type Cancellable, we can remove the Cancellable protocol from the project. Build and run the application to see the result. The behavior of the application shouldn't have changed.
What's Next?
Replacing a completion handler with a future isn't difficult and it often results in less code that is easier to reason about. The receive operator we applied to the future shows the flexibility futures, and publishers in general, add to the code you write. It is important to note that it is no longer possible to cancel the data task that fetches the data for the remote image. This is an important drawback that you need to take into consideration if you decide to replace completion handlers with futures.