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()
}
}
}
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")
}
}