Vista is a SwiftUI-first server-state management library inspired by TanStack Query.
It keeps asynchronous server state close to a View without requiring a dedicated ViewModel for loading, error, cache, and mutation state.
- iOS 17+
- macOS 14+
- Swift 5.9+
Add Vista to your Swift Package Manager dependencies.
dependencies: [
.package(url: "https://github.com/ras0q/vista.git", branch: "main")
]Then add the product to your target.
dependencies: [
.product(name: "Vista", package: "vista")
]Declare async server state at the top of your SwiftUI view.
extension QueryKey {
static func user(_ id: Int) -> Self {
QueryKey("user:\(id)")
}
}
struct UserScreen: View {
@Query<User> private var user
@Mutation<Void, Void> private var follow
init(userID: Int) {
_user = Query(.user(userID)) {
try await UserAPI.fetchUser(id: userID)
}
_follow = Mutation { () in
try await UserAPI.followUser(id: userID)
}
}
var body: some View {
// Render from user and follow directly.
EmptyView()
}
}Vista does not fetch automatically. Your view decides when work starts.
Inject one QueryClient from your app root so all child views share the same cache.
import SwiftUI
import Vista
@main
struct DemoApp: App {
private let queryClient = QueryClient()
var body: some Scene {
WindowGroup {
NavigationStack {
UserScreen(userID: 1)
}
.environment(\.queryClient, queryClient)
}
}
}If you do not inject a client, Vista falls back to a default client and logs a warning.
This example shows explicit fetch timing, loading state, cached data, refresh, and error rendering.
import SwiftUI
import Vista
struct User: Decodable, Sendable {
let id: Int
let name: String
let bio: String
}
extension QueryKey {
static func user(_ id: Int) -> Self {
QueryKey("user:\(id)")
}
}
enum UserAPI {
static func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
}
struct UserScreen: View {
@Query<User> private var user
init(userID: Int) {
_user = Query(
.user(userID),
options: QueryOptions(
staleTime: .seconds(30),
gcTime: .seconds(300)
)
) {
try await UserAPI.fetchUser(id: userID)
}
}
var body: some View {
content
.navigationTitle("Profile")
.onAppear {
Task {
await user.fetchIfNeeded()
}
}
.refreshable {
await user.refetch()
}
}
@ViewBuilder
private var content: some View {
if user.isLoading {
ProgressView("Loading user...")
} else if let data = user.data {
VStack(alignment: .leading, spacing: 12) {
Text(data.name)
.font(.title.bold())
Text(data.bio)
.foregroundStyle(.secondary)
if let error = user.error {
Text(error.localizedDescription)
.foregroundStyle(.red)
}
Button("Reload") {
Task {
await user.refetch()
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
} else if let error = user.error {
ContentUnavailableView(
"Failed to load user",
systemImage: "exclamationmark.triangle",
description: Text(error.localizedDescription)
)
} else {
ContentUnavailableView("No user", systemImage: "person.slash")
}
}
}Mutations are local to the view property. They are explicit, and follow-up invalidation is explicit too.
import SwiftUI
import Vista
enum UserAPI {
static func followUser(id: Int) async throws {
var request = URLRequest(url: URL(string: "https://example.com/users/\(id)/follow")!)
request.httpMethod = "POST"
_ = try await URLSession.shared.data(for: request)
}
}
struct UserScreen: View {
@Query<User> private var user
@Mutation<Void, Void> private var follow
init(userID: Int) {
_user = Query(.user(userID)) {
try await UserAPI.fetchUser(id: userID)
}
_follow = Mutation { () in
try await UserAPI.followUser(id: userID)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if let user = user.data {
Text(user.name)
.font(.title.bold())
}
Button(follow.isPending ? "Following..." : "Follow") {
Task {
do {
try await follow.run(())
await user.invalidate()
await user.refetch()
} catch {
}
}
}
.disabled(follow.isPending)
if let error = follow.error {
Text(error.localizedDescription)
.foregroundStyle(.red)
}
}
.padding()
.onAppear {
Task {
await user.fetchIfNeeded()
}
}
}
}Use initialData when you already have a value and want the query to start in a success state.
_user = Query(
.user(userID),
initialData: cachedUser,
initialDataUpdatedAt: cachedUserUpdatedAt
) {
try await UserAPI.fetchUser(id: userID)
}fetchIfNeeded()fetches only when data is missing, stale, or invalidated.refetch()always runs the fetch closure.invalidate()marks the exact key as stale and does not refetch automatically.reset()clears observer-local state.isLoadingis only for the initial load when no data exists yet.isFetchingis true for any active fetch, including refetches.