Infinite loops are every developer's nightmare, especially if they have disastrous consequences. You need to be mindful of infinite loops when working with Combine or any other reactive framework. The difficulty is that it isn't always obvious that you created an infinite loop. As a matter of fact, we introduced an infinite loop earlier in this series. You receive a bonus point if you can spot it.
Spotting the Infinite Loop
Open the starter project of this episode if you want to follow along. Navigate to the fetchWeatherData(for:) method in RootViewModel.swift. In the closure we pass to the tryMap operator, we make sure the status code of the response falls within the success range. Let's simulate an error by inversing the return value of the contains(_:) method on the second line of the guard statement, using the logical NOT operator.
.tryMap { data, response -> WeatherData in
guard
let response = response as? HTTPURLResponse,
!(200..<300).contains(response.statusCode)
else {
throw WeatherDataError.failedRequest
}
...
}
Before we build and run the application, we add a print statement to the completion closure of the sink(receiveCompletion:receiveValue:) method.
.sink { [weak self] completion in
print("COMPLETE", completion)
switch completion {
case .finished:
break
case .failure(let error):
self?.weatherDataStateSubject.send(.error(error))
}
} receiveValue: { [weak self] weatherData in
self?.weatherDataStateSubject.send(.data(weatherData))
}
Build and run the application and inspect the output in the console. The output shows that the request failed. This isn't surprising since we simulated a failure. Note that the view model sends another request to the weather service in response to the failure and that pattern repeats itself over and over. If you use a paying weather service and this bug makes it into production, you can expect a hefty bill. Problems like this need to be avoided at any cost.
Finding the Root Cause
The root cause of the infinite loop can be found in the setupBindings() method. We combine two publishers, currentLocationPublisher and weatherDataPublisher, and apply the compactMap operator to the resulting publisher. In the closure that is passed to the compactMap operator, we safely unwrap weatherData. If weatherData is equal to nil, the closure returns the location emitted by currentLocationPublisher. That is the root cause of the problem.
If the request fails, weatherData is equal to nil and the location emitted by currentLocationPublisher is emitted by the publisher the compactMap operator returns. In the sink(receiveValue:) method, the location is used to initiate a request for weather data.
Breaking the Infinite Loop
We explore a number of solutions to address this problem. Some solutions are fine while others should be avoided. Let me show you the first solution to break the infinite loop.
Instead of combining currentLocationPublisher with weatherDataPublisher, we combine currentLocationPublisher with weatherDataStateSubject. This means the closure that is passed to the compactMap operator accepts a CLLocation object as its first argument and a WeatherDataState object as its second argument.
currentLocationPublisher.combineLatest(weatherDataStateSubject)
.compactMap { location, weatherDataState -> CLLocation? in
}
.sink { [weak self] location in
self?.fetchWeatherData(for: location)
}.store(in: &subscriptions)
In the closure, we switch on the WeatherDataState object. The closure returns the CLLocation object if weatherDataState is equal to loading. The closure also returns the CLLocation object if the weather data is older than one hour. To break the infinite loop, the closure returns nil if the request for weather data failed, that is, if weatherDataState is equal to error.
currentLocationPublisher.combineLatest(weatherDataStateSubject)
.compactMap { location, weatherDataState -> CLLocation? in
switch weatherDataState {
case .loading:
return location
case .data(let weatherData):
return Date().timeIntervalSince(weatherData.time) > 3_600
? location
: nil
case .error:
return nil
}
}
.sink { [weak self] location in
self?.fetchWeatherData(for: location)
}.store(in: &subscriptions)
Pure Functions
I would like to show you another solution to avoid an infinite loop. Let's start with the original implementation that causes an infinite loop. In setupBindings(), we combine currentLocationPublisher and weatherDataPublisher. The only reason we combine currentLocationPublisher with weatherDataPublisher is to have access to the most recent weather data. We use the timestamp of the weather data in the closure that is passed to the compactMap operator to decide whether the weather data is stale.
currentLocationPublisher.combineLatest(weatherDataPublisher)
.compactMap { location, weatherData -> CLLocation? in
guard let weatherData = weatherData else {
return location
}
return Date().timeIntervalSince(weatherData.time) > 3_600
? location
: nil
}
.sink { [weak self] location in
self?.fetchWeatherData(for: location)
}.store(in: &subscriptions)
One solution is to hold on to the most recent weather data the view model fetches from the weather service and access it from within the closure that is passed to the compactMap operator. Let me show you how that works. We first change the type of the weatherDataStateSubject property. We can make it hold on to the most recent weather data by declaring it as a CurrentValueSubject instance. Because a current value subject always has an initial value, we change the Output type of the current value subject to WeatherDataState? and set its initial value to nil.
private let weatherDataStateSubject = CurrentValueSubject<WeatherDataState?, Never>(nil)
Because the weatherDataStatePublisher property relies on the weatherDataStateSubject property, we apply the compactMap operator to filter out nil values.
var weatherDataStatePublisher: AnyPublisher<WeatherDataState, Never> {
weatherDataStateSubject
.compactMap { $0 }
.eraseToAnyPublisher()
}
Revisit the setupBindings() method. We no longer combine currentLocationPublisher and weatherDataPublisher. We apply the compactMap operator to currentLocationPublisher and access the current value of weatherDataStateSubject. We use a capture list to weakly reference the view model from within the closure that is passed to the compactMap operator and safely access the most recent weather data.
currentLocationPublisher
.compactMap { [weak self] location -> CLLocation? in
guard let weatherData = self?.weatherDataStateSubject.value?.weatherData else {
return location
}
return Date().timeIntervalSince(weatherData.time) > 3_600
? location
: nil
}
.sink { [weak self] location in
self?.fetchWeatherData(for: location)
}.store(in: &subscriptions)
Build and run the application to see the result. The request for weather data fails, which is expected, but notice that we no longer end up with an infinite loop. Even though the solution we implemented works, it isn't a solution I can recommend. Let me explain why that is.
Earlier in this series, we discussed functional programming and pure functions. The closure or function that is passed to the compactMap operator should ideally be a pure function. This means that it should only operate on the arguments it is given. That is no longer true. The closure accesses the most recent weather data through the view model's weatherDataStateSubject property. Even though the solution works, this is a code smell and not a solution I can recommend.
With Latest From
Let me show you one last solution to avoid an infinite loop. Let's start with the original implementation that causes an infinite loop. RxSwift defines an operator that does exactly what we need. It defines an operator with name withLatestFrom. The operator is similar to the combineLatest operator. The difference is that the resulting publisher doesn't emit an event if the publisher that is passed to the withLatestFrom operator emits an event. Unfortunately, the Combine framework doesn't have an equivalent operator.

We can achieve the same result using the removeDuplicates operator. We apply the removeDuplicates operator, passing in a closure. The closure accepts two tuples as arguments, the first tuple is the previous value and the second tuple is the next value. Each tuple consists of an optional CLLocation object and a WeatherData object.
We apply the removeDuplicates operator to make sure the publisher only publishes a CLLocation object if it doesn't match the previous CLLocation object. Notice that we ignore the WeatherData objects in the closure that is passed to the removeDuplicates operator.
let weatherDataPublisher = weatherDataStateSubject
.map { $0.weatherData }
currentLocationPublisher.combineLatest(weatherDataPublisher)
.removeDuplicates(by: { previous, next -> Bool in
previous.0 == next.0
})
.compactMap { location, weatherData -> CLLocation? in
guard let weatherData = weatherData else {
return location
}
return Date().timeIntervalSince(weatherData.time) > 3_600
? location
: nil
}
.sink { [weak self] location in
self?.fetchWeatherData(for: location)
}.store(in: &subscriptions)
We can simplify the closure that is passed to the removeDuplicates operator by using shorthand argument names.
let weatherDataPublisher = weatherDataStateSubject
.map { $0.weatherData }
currentLocationPublisher.combineLatest(weatherDataPublisher)
.removeDuplicates { $0.0 == $1.0 }
.compactMap { location, weatherData -> CLLocation? in
guard let weatherData = weatherData else {
return location
}
return Date().timeIntervalSince(weatherData.time) > 3_600
? location
: nil
}
.sink { [weak self] location in
self?.fetchWeatherData(for: location)
}.store(in: &subscriptions)
This solution is much better than the previous one and I would favor this solution over the first solution we discussed in this episode.
Mind the Infinite Loop
You need to avoid scenarios in which an event published by a publisher can cause that publisher to publish another event. What do I mean by that? In the original implementation, we combine currentLocationPublisher and weatherDataPublisher into a single publisher. Every time the resulting publisher of the compactMap operator emits a value, the sink(receiveValue:) method is executed and a request for weather data is initiated.
The fetchWeatherData(for:) method is responsible for initiating the request for weather data and when the request completes, successfully or unsuccessfully, the weatherDataPublisher emits a value. That causes the sequence to repeat, resulting in an infinite loop.
What's Next?
Reactive programming has many benefits and I wouldn't want to build applications without it. It also comes with a few warnings, but that shouldn't stop you from adopting it in a project. With great power comes great responsibility. It takes time to become familiar with the finer details of reactive programming. The more you use frameworks like Combine, the more confident you will become building reactive applications.