Design Converter
Education
Last updated on Jan 20, 2025
•5 mins read
Last updated on Jan 6, 2025
•5 mins read
When working with asynchronous operations in Swift, such as handling network requests, completion handlers are a powerful and frequently used tool. Simply put, a completion handler is a closure (or a closure block) you pass as a parameter to a function, allowing you to execute some code once a task is finished. This approach is particularly useful for managing asynchronous functions like fetching data from a remote server or processing user input in the background.
In this blog, we’ll dive into the technicalities of completion handlers, how they work, and why they are essential. By the end, you’ll be able to use Swift completion handlers effectively in your projects.
A completion handler in Swift is a function argument that is called once the execution of a particular task is finished. This design helps you manage operations like network calls or other asynchronous functions efficiently, ensuring that your main thread isn’t blocked.
For example, consider a network request where the app fetches data from a server. You wouldn’t want the app to freeze while waiting for the server’s response. Instead, you use a completion handler to define what happens after the network request is completed.
A completion handler typically:
Here’s the basic syntax:
1func fetchData(from url: String, completion: @escaping (Data?, Error?) -> Void) { 2 // Perform the network request 3 // Call completion with the result 4}
In the above function, the completion
parameter is marked with @escaping
to ensure it survives beyond the scope of the function. This is crucial for asynchronous operations.
Let’s look at an example of using a completion handler to handle a network request:
1import UIKit 2 3func fetchUserData(url: String, completion: @escaping (Result<[String: Any], Error>) -> Void) { 4 guard let url = URL(string: url) else { 5 completion(.failure(NSError(domain: "", code: 400, userInfo: nil))) 6 return 7 } 8 9 let task = URLSession.shared.dataTask(with: url) { data, response, error in 10 if let error = error { 11 completion(.failure(error)) 12 return 13 } 14 15 guard let data = data else { 16 completion(.failure(NSError(domain: "", code: 500, userInfo: nil))) 17 return 18 } 19 20 do { 21 if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { 22 completion(.success(json)) 23 } else { 24 completion(.failure(NSError(domain: "", code: 500, userInfo: nil))) 25 } 26 } catch { 27 completion(.failure(error)) 28 } 29 } 30 task.resume() 31} 32 33// Usage 34fetchUserData(url: "https://api.example.com/user") { result in 35 switch result { 36 case .success(let data): 37 print("User Data: \(data)") 38 case .failure(let error): 39 print("Error: \(error)") 40 } 41}
data
) or failure (error
).Result
type simplifies error handling and makes the completion handler more expressive.resume()
method starts the network call.When multiple asynchronous operations depend on each other, you might end up with nested completion handlers, leading to complex and hard-to-read code. Here’s an example of what you should avoid:
1func fetchUserDetails(userId: String, completion: @escaping (User) -> Void) { 2 fetchUserData(url: "https://api.example.com/user/\(userId)") { result in 3 switch result { 4 case .success(let user): 5 fetchUserPosts(userId: user.id) { posts in 6 completion(User(user: user, posts: posts)) 7 } 8 case .failure(let error): 9 print("Error: \(error)") 10 } 11 } 12}
To simplify, consider using techniques like async/await
introduced in Swift 5.5 or chaining completion handlers smartly.
When using completion handlers, it’s vital to manage memory management correctly. A retain cycle can occur if a closure captures a view controller strongly. To prevent memory leaks, use [weak self]
in the closure block:
1func loadData() { 2 fetchUserData(url: "https://api.example.com/user") { [weak self] result in 3 guard let self = self else { return } 4 switch result { 5 case .success(let data): 6 self.updateUI(with: data) 7 case .failure(let error): 8 print("Error: \(error)") 9 } 10 } 11}
This ensures the view controller is not retained indefinitely.
In Swift, you can use trailing closure syntax to make your code cleaner:
1fetchUserData(url: "https://api.example.com/user") { result in 2 print("Result: \(result)") 3}
Trailing closures are especially helpful when the completion handler is the last argument in the function.
A robust completion handler must handle errors gracefully. Use patterns like Result
or nil
values to indicate failure:
1func fetchData(url: String, completion: @escaping (Data?, Error?) -> Void) { 2 // Simulating a network call 3 let success = Bool.random() 4 if success { 5 completion(Data(), nil) 6 } else { 7 completion(nil, NSError(domain: "", code: 400, userInfo: nil)) 8 } 9}
A completion handler in Swift is an essential tool for working with asynchronous operations, especially in scenarios involving network requests. By understanding how to design and use them effectively, you can write cleaner, more efficient code. Ensure you avoid retain cycles, handle errors properly, and leverage techniques like trailing closures to improve readability.
With this knowledge, you’re now better equipped to implement Swift completion handlers in your apps, making your asynchronous workflows seamless and robust.
Tired of manually designing screens, coding on weekends, and technical debt? Let DhiWise handle it for you!
You can build an e-commerce store, healthcare app, portfolio, blogging website, social media or admin panel right away. Use our library of 40+ pre-built free templates to create your first application using DhiWise.