Search code examples
iosswiftswiftui

How can I use previews with example data for views that make API calls using the task modifier or a model?


My app makes API calls to fetch a lot of its data, either by using a .task modifier if it is a simple few and a small amount of data, or by using a model. A little to my surprise the previews actually run the code fetching the data. However, many API calls require authentication and a server to call to, having authentication in the repo (even if it is a test account) doesn't seem like a good practice, and having the preview tied to a (test or demo) server doesn't seem great either. How can I use example data in my previews for views that fetch data?

struct ListView: View {
    @State private var books: [Book]

    var body: some View {
        List(books) { book in
            Text(book.title)
        }
        .task {
            books = await HTTPClient.fetchBooks()
        }
    }
}
struct ListView: View {
    @State private var model = BookModel()

    var body: some View {
        List(model.books) { book in
            Text(book.title)
        }
        .task {
            await model.fetchBooks()
        }
    }
}

Solution

  • You should create a protocol like this:

    protocol HTTPClient {
        static func fetchBooks() async throws -> [Book]
    }
    

    and have two implementations of this protocol:

    enum RealHTTPClient: HTTPClient {
        static func fetchBooks() async throws -> [Book] {
            // ... do actual work here!
        }
    }
    
    enum MockHTTPClient: HTTPClient {
        static func fetchBooks() -> [Book] {
            // return fake data here
            [Book(title: "Book 1"), Book(title: "Book 2"), Book(title: "Book 3")]
        }
    }
    

    Then, inject this into the Environment.

    extension EnvironmentValues {
        // using the @Entry macro in iOS 18 for convenience
        // you don't have to use @Entry. Just do it the old way if you are on a lower version
        @Entry var httpClient: any HTTPClient.Type = MockHTTPClient.self
    }
    

    Here I have made the MockHTTPClient as the default value. This makes using the real client opt-in. Also I am using meta types and static methods here to avoid concurrency issues. If you need the HTTP clients to have state, you would need to make sure they are not passed across concurrency contexts, but that's out of the scope of this question.

    In your view, you can then do:

    struct ContentView: View {
        @State private var books: [Book] = []
        @Environment(\.httpClient) var httpClient
    
        var body: some View {
            List(books) { book in
                Text(book.title)
            }
            .task {
                do {
                    books = try await httpClient.fetchBooks()
                } catch {
                    print(error)
                }
            }
        }
    }
    
    #Preview {
        ContentView() // this will not call the real server because the default value of httpClient is MockHTTPClient.self
    }
    

    At the root view, you can inject the real HTTP client.

    WindowGroup {
        RootView()
    }
    .environment(\.httpClient, RealHTTPClient.self)
    

    If you want to have a fetchBooks method in BookModel, it should take a HTTPClient.Type as parameter:

    @Observable
    class BookModel {
        var books = [Book]()
        
        // don't worry about this @MainActor - nonisolated async operations
        // you do in the body of fetchBooks is still running off of the MainActor
        @MainActor
        func fetchBooks(with httpClient: any HTTPClient.Type) async throws {
            // as an example
            books = try await httpClient.fetch(url: "https://example.com/books")
        }
    }