← Back to blog

Building a Pokédex on iOS — Part 8: ViewModel with @Observable

Part 8 of the PokeTracker series: we build PokemonListViewModel with the new @Observable macro, handling pagination, search, filters, and the cache-first logic.

With all the data layers in place, we now build the presentation layer. The ViewModel is the bridge between data and the view: it holds state, exposes it for SwiftUI to observe, and responds to user actions.


@Observable vs ObservableObject

Before iOS 17, ViewModels used ObservableObject with @Published:

// Old pattern — still works but no longer preferred
class PokemonListViewModel: ObservableObject {
    @Published var pokemons: [Pokemon] = []
    @Published var isLoading = false
}

iOS 17 introduced the @Observable macro. The most important difference isn’t syntax — it’s performance: with ObservableObject, any change to any @Published property causes every view observing that object to re-evaluate. With @Observable, SwiftUI tracks exactly which properties each view reads and only re-renders when those specific properties change.

// New pattern — more granular and efficient
@Observable
final class PokemonListViewModel {
    var pokemons: [Pokemon] = []
    var isLoading = false
    // No @Published, no ObservableObject
}

In a list with 20+ items, this makes a real difference.


PokemonListViewModel

Create Features/Pokemon/Presentation/ViewModels/PokemonListViewModel.swift. This ViewModel is the most complete one in the app:

import Foundation
import SwiftData
import Observation

enum SortOption: String, CaseIterable {
    case number   = "Number"
    case nameAsc  = "Name (A-Z)"
    case nameDesc = "Name (Z-A)"
}

@MainActor
@Observable
final class PokemonListViewModel {

    // MARK: - Observable state

    private(set) var pokemons: [Pokemon] = []
    private(set) var filteredPokemons: [Pokemon] = []
    private(set) var favorites: Set<Int> = []
    private(set) var isLoading = false
    private(set) var isLoadingMore = false
    private(set) var error: Error?
    private(set) var hasMorePages = true
    private(set) var totalCount = 0

    var searchText = ""        { didSet { filterPokemons() } }
    var showFavoritesOnly = false { didSet { filterPokemons() } }
    var isGridView = false
    var sortOption: SortOption = .number { didSet { filterPokemons() } }
    var selectedType: PokemonType? = nil  { didSet { filterPokemons() } }
    var selectedGeneration: Int? = nil    { didSet { filterPokemons() } }

    // MARK: - Dependencies

    private let getPokemonListUseCase: GetPokemonListUseCaseProtocol
    private var dataService: PokemonDataService?

    // MARK: - Pagination

    private let pageSize = 20
    private var currentOffset = 0

    init(getPokemonListUseCase: GetPokemonListUseCaseProtocol = GetPokemonListUseCase()) {
        self.getPokemonListUseCase = getPokemonListUseCase
    }

    func configure(with modelContext: ModelContext) {
        self.dataService = PokemonDataService(modelContext: modelContext)
        loadCachedData()
    }

    // MARK: - Data loading

    func loadInitialData() async {
        guard !isLoading else { return }
        isLoading = true
        error = nil
        currentOffset = 0

        do {
            let result = try await getPokemonListUseCase.execute(limit: pageSize, offset: 0)
            pokemons = result.pokemons
            hasMorePages = result.hasMore
            totalCount = result.total
            currentOffset = pageSize

            try dataService?.clearCachedPokemons()
            try dataService?.cachePokemons(result.pokemons, startingOrder: 0)

            filterPokemons()
        } catch {
            self.error = error
            loadCachedData() // if the network fails, show cached data
        }

        isLoading = false
    }

    func loadMoreIfNeeded(currentItem: Pokemon) async {
        guard hasMorePages,
              !isLoadingMore,
              !isLoading,
              pokemons.last?.id == currentItem.id else { return }

        isLoadingMore = true

        do {
            let result = try await getPokemonListUseCase.execute(limit: pageSize, offset: currentOffset)
            pokemons.append(contentsOf: result.pokemons)
            hasMorePages = result.hasMore
            try dataService?.cachePokemons(result.pokemons, startingOrder: currentOffset)
            currentOffset += pageSize
            filterPokemons()
        } catch {
            self.error = error
        }

        isLoadingMore = false
    }

    func refresh() async {
        await loadInitialData()
    }

    func toggleFavorite(_ pokemon: Pokemon) {
        do {
            if let isFavorite = try dataService?.toggleFavorite(pokemon) {
                if isFavorite { favorites.insert(pokemon.id) }
                else { favorites.remove(pokemon.id) }
                filterPokemons()
            }
        } catch {
            self.error = error
        }
    }

    func isFavorite(_ pokemon: Pokemon) -> Bool {
        favorites.contains(pokemon.id)
    }

    // MARK: - Cached data

    private func loadCachedData() {
        do {
            if let cached = try dataService?.getCachedPokemons(), !cached.isEmpty {
                pokemons = cached
                hasMorePages = true
                currentOffset = cached.count
            }
            if let favs = try dataService?.getFavorites() {
                favorites = Set(favs.map { $0.id })
            }
            filterPokemons()
        } catch {
            self.error = error
        }
    }

    // MARK: - Filters

    private func filterPokemons() {
        var result = pokemons

        if !searchText.isEmpty {
            let query = searchText.lowercased()
            result = result.filter {
                $0.name.lowercased().contains(query) || $0.formattedID.contains(query)
            }
        }

        if showFavoritesOnly {
            result = result.filter { favorites.contains($0.id) }
        }

        if let generation = selectedGeneration {
            let range = generationRange(for: generation)
            result = result.filter { range.contains($0.id) }
        }

        if let type = selectedType {
            // Type filtering requires detail data — for now filters by type name if available
            _ = type
        }

        switch sortOption {
        case .number:   result.sort { $0.id < $1.id }
        case .nameAsc:  result.sort { $0.name < $1.name }
        case .nameDesc: result.sort { $0.name > $1.name }
        }

        filteredPokemons = result
    }

    private func generationRange(for generation: Int) -> ClosedRange<Int> {
        switch generation {
        case 1: return 1...151
        case 2: return 152...251
        case 3: return 252...386
        case 4: return 387...493
        case 5: return 494...649
        case 6: return 650...721
        case 7: return 722...809
        case 8: return 810...905
        case 9: return 906...1025
        default: return 1...1025
        }
    }
}

A few decisions worth explaining:

private(set) on state properties. The view can read these properties but can’t write to them directly. State only changes through the ViewModel’s methods. This makes it impossible for the view to leave the ViewModel in an inconsistent state.

filterPokemons() in didSet. Every time the user changes the search text, the selected type, or the generation, filteredPokemons is recalculated. The view always reads from filteredPokemons, never from pokemons directly.

The cache-first strategy. In loadInitialData: if the network fails, loadCachedData() shows whatever is in SwiftData. If the network succeeds, it updates the cache. The user never sees an empty screen if they’ve already downloaded data before.

configure(with:) receives the ModelContext from the view rather than creating it in the ViewModel. This is because ModelContext comes from the SwiftUI environment and isn’t available until the view mounts.


PokemonListScreen

Create Features/Pokemon/Presentation/Views/Screens/PokemonListScreen.swift. This is the main screen:

import SwiftUI
import SwiftData

struct PokemonListScreen: View {

    @Environment(\.modelContext) private var modelContext
    @State private var viewModel = PokemonListViewModel()
    @State private var selectedPokemon: Pokemon?
    @State private var showFilters = false

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Pokédex")
                .searchable(text: $viewModel.searchText, prompt: "Search Pokémon")
                .toolbar { toolbarContent }
                .refreshable { await viewModel.refresh() }
        }
        .task {
            viewModel.configure(with: modelContext)
            if viewModel.pokemons.isEmpty {
                await viewModel.loadInitialData()
            }
        }
        .sheet(item: $selectedPokemon) { pokemon in
            NavigationStack {
                PokemonDetailScreen(pokemon: pokemon)
            }
        }
        .sheet(isPresented: $showFilters) {
            FilterScreen(
                selectedType: $viewModel.selectedType,
                selectedGeneration: $viewModel.selectedGeneration
            )
        }
    }

    @ViewBuilder
    private var content: some View {
        if viewModel.isLoading && viewModel.pokemons.isEmpty {
            ProgressView("Loading Pokémon...")
        } else if let error = viewModel.error, viewModel.pokemons.isEmpty {
            ContentUnavailableView {
                Label("Error", systemImage: "exclamationmark.triangle")
            } description: {
                Text(error.localizedDescription)
            } actions: {
                Button("Try Again") {
                    Task { await viewModel.loadInitialData() }
                }
                .buttonStyle(.borderedProminent)
            }
        } else if viewModel.filteredPokemons.isEmpty {
            ContentUnavailableView("No Pokémon Found", systemImage: "magnifyingglass")
        } else {
            pokemonList
        }
    }

    private var pokemonList: some View {
        List {
            ForEach(viewModel.filteredPokemons) { pokemon in
                Text(pokemon.displayName) // placeholder — in the views post we build PokemonRowView
                    .onTapGesture { selectedPokemon = pokemon }
                    .onAppear {
                        Task { await viewModel.loadMoreIfNeeded(currentItem: pokemon) }
                    }
            }

            if viewModel.isLoadingMore {
                HStack { Spacer(); ProgressView(); Spacer() }
                    .listRowSeparator(.hidden)
            }
        }
        .listStyle(.plain)
    }

    @ToolbarContentBuilder
    private var toolbarContent: some ToolbarContent {
        ToolbarItem(placement: .topBarLeading) {
            if viewModel.totalCount > 0 {
                Text("\(viewModel.pokemons.count)/\(viewModel.totalCount)")
                    .font(.caption).foregroundStyle(.secondary)
            }
        }
        ToolbarItem(placement: .topBarTrailing) {
            Button {
                withAnimation { viewModel.isGridView.toggle() }
            } label: {
                Image(systemName: viewModel.isGridView ? "list.bullet" : "square.grid.2x2")
            }
        }
    }
}

#Preview {
    PokemonListScreen()
        .modelContainer(for: [CachedPokemon.self, FavoritePokemon.self, CachedPokemonDetail.self], inMemory: true)
}

@State private var viewModel = PokemonListViewModel() instead of @StateObject. With @Observable, the ViewModel is created like any @State struct. You don’t need @StateObject, @ObservedObject, or environmentObject.

The #Preview creates an in-memory ModelContainer — preview data doesn’t persist between restarts.


What’s next

In Post 9 we build unit tests with Swift Testing. We’ll see how the protocols we defined in previous posts make testing straightforward.