iosswiftuitableviewasynchronous

How to Cancel Previous Asynchronous Requests?


Background

I'm working on a UITableView that loads with preloaded data, then fetches additional items asynchronously. When I spam pull-to-refresh, each pull triggers a new async request, resulting in multiple callbacks and duplicate items.

I need to ensure that:

Only the latest create() call's async results are processed. Previous async requests are ignored or canceled, avoiding duplicate items.


Solution

  • You asked:

    To handle this, I need a way to cancel any previous pending requests each time pull-to-refresh is triggered, ensuring only the latest request is processed.

    And in the comments you clarified:

    I’m aiming for an abstraction layer where providers can supply items asynchronously, without needing to know or enforce how they’re doing it.

    You provided a completion-handler-based example; an asynchronous API for AsyncProvider that was merely loadItems(completion:). And more to the point, you did not specify any explicit cancellation mechanism for either AsyncProvider in general, or for the specific call to loadItems in particular. In short, you have not specified any mechanism to cancel the request.

    If you survey the completion-handler-based asynchronous API, there are a variety of different cancellation patterns, including (but not limited to):

    Bottom line, there is no single consistent pattern for canceling completion-handler-based asynchronous API (though most well-written API offer some sort of cancelation mechanism). Thus, we cannot offer a single pattern for canceling some generic completion-handler-based AsyncProvider. Or, if you did (for example, a protocol modeled after the PHImageManager pattern) you’d have to write some shim for those asynchronous API that adopt different patterns.


    In Swift concurrency, however, there is a single, consistent mechanism for canceling asynchronous work. So, let us consider an async-await rendition of your AsyncProvider:

    final class AsyncProvider {
        func loadItems() async throws -> [String] {
            try await Task.sleep(for: .seconds(5))
            return ["Async Item"]
        }
    }
    

    Now the view controller (or what have you) the pattern would be:

    class ViewController: UIViewController {
        private var previousTask: Task<[String], Error>?
    
        …
    
        func reloadData() async throws {
            // cancel prior task, if any, here
            previousTask?.cancel()
    
            // wrap `loadItems` in a `Task`
            let task = Task {
                try await provider.loadItems()
            }
    
            // save it
            previousTask = task
    
            // now await it (and handle cancellation, too)        
            try await withTaskCancellationHandler { 
                let items = try await task.value
                // do something with items here
            } onCancel: { 
                task.cancel()
            }
        }
    }
    

    The difference between the legacy completion-handler-based approaches and this Swift concurrency pattern is that the latter offers a consistent cancellation pattern.

    Swift concurrency simplifies this “layer of abstraction”, because every Swift concurrency API should handle cancellation via the same mechanism, namely the cancellation of the task.


    You said:

    When calling loadItems(), it can potentially create a new instance with the same data each time. However, because each instance is technically unique, simply checking if the item already exists in the items array before adding it won’t work.

    I might encourage you to investigate “diffable data sources”. See WWDC 2019 video Advances in UI Data Sources. It greatly simplifies the process and gracefully handles this “I’ve got an updated data model with a mix of unchanged data and updates; refresh the table for me” process.