Swift UI Data Fetching for React Native Developers
Over years of working with React Native, the grass has occasionally looked greener over in Swift & Swift UI land... especially when dealing with highly stylized UI and native platform capabilities.
When I've taken the plunge and decided to build with it, I've found it really easy to write UI code that felt great. Aside from some of the less familiar Swift UI features like property wrappers and some quirks around view modifiers, it's been mostly intuitive and fun to use.
But things would always start to fall apart when I'd move onto fetching data from an API.
Simply fetching JSON from an API has always felt much more verbose than I'm used to from working in TypeScript.
The last time I picked up Swift UI, a year ago, I was exploring some tutorials and blog posts on data fetching, and I found a post that offered a decent API:
import Foundation
extension Request where Response == FetchListItemsOKResponse {
static func fetchListItems(owner: String) -> Self {
Request(
url: URL(string: "https://api.danscan.xyz/list")!,
method: .get(),
)
}
}
struct FetchListItemsOKResponse: Decodable {
var listItems: [ListItem]
}
Unfortunately, that required all this boilerplate:
import Foundation
import Combine
struct Request<Response> {
let url: URL
let method: HttpMethod
var headers: [String: String] = [:]
}
enum HttpMethod: Equatable {
case get([URLQueryItem])
case put(Data?)
case post(Data?)
case delete
case head
var name: String {
switch self {
case .get: return "GET"
case .put: return "PUT"
case .post: return "POST"
case .delete: return "DELETE"
case .head: return "HEAD"
}
}
}
extension Request {
var urlRequest: URLRequest {
var request = URLRequest(url: url)
switch method {
case .post(let data),
.put(let data):
request.httpBody = data
case let .get(queryItems):
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.queryItems = queryItems
guard let url = components?.url else {
preconditionFailure("Couldn't create a url from components...")
}
request = URLRequest(url: url)
default:
break
}
request.allHTTPHeaderFields = headers
request.httpMethod = method.name
return request
}
}
extension URLSession {
enum Error: Swift.Error {
case networking(URLError)
case decoding(Swift.Error)
}
func publisher(
for request: Request<Data>
) -> AnyPublisher<Data, Swift.Error> {
dataTaskPublisher(for: request.urlRequest)
.mapError(Error.networking)
.map(\.data)
.eraseToAnyPublisher()
}
func publisher(
for request: Request<URLResponse>
) -> AnyPublisher<URLResponse, Swift.Error> {
dataTaskPublisher(for: request.urlRequest)
.mapError(Error.networking)
.map(\.response)
.eraseToAnyPublisher()
}
func publisher<Value: Decodable>(
for request: Request<Value>,
using decoder: JSONDecoder = .init()
) -> AnyPublisher<Value, Swift.Error> {
dataTaskPublisher(for: request.urlRequest)
.mapError(Error.networking)
.map(\.data)
.decode(type: Value.self, decoder: decoder)
.mapError(Error.decoding)
.eraseToAnyPublisher()
}
}
Ugh. This feels like undifferentiated heavy lifting... Just let me fetch
!
This is about where I decided I'd quit Swift UI for a year.
A Better Way
I'm a big fan of Zod. I currently use it for data validation in all of my TypeScript projects.
In a React Native app, I might fetch data in a component like so:
// The list component, which fetches list items on render
export function MyList() {
const { data } = useQuery(['ListItems'], fetchListItems);
return <FlatList data={data} renderItem={renderListItem} />;
}
// The callback that renders each list item component
function renderListItem({ item }: ListRenderItemInfo<ListItem>) {
return (
<View>
<Text>{item.title}</Text>
<Text>{item.description}</Text>
</View>
);
}
// The list item type, derived from the Zod schema below
type ListItem = z.infer<typeof ListItemSchema>;
// The zod schema describing the list item type
const ListItemSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().nullish(),
});
// The zod schema describing the success result of the API endpoint fetched below
const FetchListItemsResultSchema = z.array(ListItemSchema);
// The query function that fetches and validates list items
async function fetchListItems() {
const response = await fetch('https://api.danscan.xyz/list');
const result = await response.json();
return FetchListItemsResultSchema.parse(result);
}
If your approach is similar to this, it turns out there's a really nice way to do something analogous in Swift UI using Codable
from Foundation (which is a core library, not an external dependency like Zod).
Let's take the above React Natve example, and map it onto Swift UI using Foundation:
import SwiftUI
import Foundation
import Combine
// The view, which fetches list items when it appears
struct MyList: View {
@StateObject private var viewModel = ListViewModel()
var body: some View {
List(viewModel.volumeResults, id: \.id) { item in
VStack {
Text(item.title)
Text(item.description)
}
}
.onAppear { viewModel.fetchListItems() }
}
}
// Equivalent to the `ListItem` type and `ListItemSchema` from the TypeScript example
struct ListItem: Codable, Hashable, Identifiable {
let id: String
let title: String
let description: String?
enum CodingKeys: String, CodingKey {
case id, title, description
}
}
class ListViewModel: ObservableObject {
@Published var listItems: [ListItem] = []
private var cancellable: AnyCancellable?
// Equivalent to `fetchListItems` from the TypeScript example, albeit a bit more verbose than `fetch`
func fetchListItems() {
let url = URL(string: "https://api.danscan.xyz/list")!
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: [ListItem].self, decoder: JSONDecoder())
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.listItems = results
}
}
deinit {
cancellable?.cancel()
}
}
This looks much better than all of the Request
boilerplate from above.
Having found a way to map so cleanly from what I'm familiar with to Swift UI, I think I'll spend a lot more time exploring it, whether integrating it into my existing React Native projects via Expo's Modules API, or by writing some new projects fully in Swift UI.