SwiftData: Simplifying Persistence in iOS Apps

Learn all about SwiftData, a new framework introduced at WWDC 2023 that provides a Swift-like API for working with persistence in iOS apps and simplifies Core Data usage. By Josh Steele.

4.8 (4) · 1 Review

Save for later
Share

It’s the dawn of a new day for dealing with persisting data in your iOS apps.

SwiftData is here.

OK, hyperbole aside, users of Core Data have waited for SwiftData for a long time. What is SwiftData, and why should you care?

What Is SwiftData?

Before diving into some of the details of SwiftData, you need to know what it is. SwiftData was introduced at WWDC 2023 and is a framework that provides a Swift-like API for working with persistence in your app. You might even say it’s “Swift native”.

An important distinction to make here is that SwiftData still uses the underlying storage architecture of Core Data. SwiftData simply presents a more user-friendly syntax for working with Core Data.

Actually, “simply” is a poor choice of words. If you’ve worked with Core Data in the past, you’ll find SwiftData’s new syntax simply amazing.

To understand why it’s so amazing, a small look back is required.

A Brief Look Back

Ever since Swift came out, using Core Data with your app has always seemed out of place. All of the “Swift-y” features that came out each year with Swift and SwiftUI were leaving Core Data, which had a deep Objective C heritage, in the dust.

A good example here is the .xcdatamodeld, or Schema Model Editor, file. This file is used to define your database’s schema.

The Schema Model Editor

This is a convenient way to define all the elements of your model, but it feels separate from the rest of your code. In fact, the compiler uses the schema to make class files for you, but they’re located in the derived data of your project! This technique also differs from the approach taken in SwiftUI, which pushes developers toward defining everything in code instead of separate helper files like storyboards.

Incremental Changes

This isn’t to say that Apple was ignoring Core Data. Each WWDC would see some welcome improvements to the framework. The creation of the NSPersistentCloudKitContainer encapsulated a large chunk of code that developers normally had to write themselves to keep their Core Data and CloudKit stores in sync. The introduction of property wrappers such as @FetchRequest and @SectionedFetchRequest helped keep SwiftUI views in sync with the database just like a normal @State/@Binding pair. In fact, property wrappers gave a lot of people hope that something could be done to make Core Data a bit more “Swift-y”.

Then Swift 5.9 was released.

Swift Macros and Swift Data

The introduction of Swift macros in Swift 5.9 looks like it’ll be a game changer. There’s sure to be a lot of content here at Kodeco to cover Swift macros in the near future, so for now, here are some of the highlights while checking out SwiftData.

Here’s a model for a Recipe class:

class Recipe {
    var name: String
    var summary: String?
    var ingredients: [Ingredient]
}

If I were using Core Data, I’d have to go into the Schema Editor, add a new entity, and add attributes for the properties. With SwiftData, that’s all done with one addition, @Model:

import SwiftData

@Model
class Recipe {
    var name: String
    var summary: String?
    var ingredients: [Ingredient]
}

That’s it! Our Recipe class is now a valid model for use in SwiftData, which has its own import when you want to use it. But what exactly is @Model? Right-clicking on @Macro and choosing Expand Macro shows exactly what this macro has added to your class:

The expanded @Model macro

That’s a lot of added code! The @Model macro sets up a perfectly valid model, but you can also make customizations. For example, to ensure the name is unique, you can add a macro to that property:

@Model
class Recipe {
    @Attribute(.unique) var name: String
    var summary: String?
    var ingredients: [Ingredient]
}

You can even define deletion rules for the relationships using the @Relationship macro:

@Model
class Recipe {
    @Attribute(.unique) var name: String
    var summary: String?

    @Relationship(.cascade)
    var ingredients: [Ingredient]
}

Associating the Model With Your App

Gone are the days of the Persistence.swift file for initializing the persistence stack for your app. SwiftData has a new modifier that lets you define exactly which types you want to consider part of your model:

@main
struct RecipeApp: App {

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Recipe.self, Ingredient.self])
    }
}

The modelContainer(for:) modifier takes an array of types you want your model to track.

That’s it! There’s no step 2! But what about accessing the data?

Accessing Data

With a model defined and the modelContainer injected into the environment, you can access your database entries!

@Query var recipes: [Recipe]
var body: some View {
    List(recipes) { recipe in
        NavigationLink(recipe.name, destination: RecipeView(recipe))
    }
}

That’s it! There’s still no step 2! You can, however, customize the query to handle things like sorting:

@Query(sort: \Recipe.name, order: .forward) 
var recipes: [Recipe]

var body: some View {
    List(recipes) { recipe in
        NavigationLink(recipe.name, destination: RecipeView(recipe))
    }
}

Inserting and Deleting Data

To insert and delete data from the datastore in Core Data, you needed access to the store’s context. The same is true for SwiftData. When you set up the .modelContainer earlier, that also set up a default model context and injected it into the environment. This allows all SwiftUI views in the hierarchy to access it via the \.modelContext key path in the environment.

Once you have that, you can use context.insert() and context.delete() calls to insert and delete objects from the context.

struct RecipesView: View
{
  @Environment(\.modelContext) private var modelContext

  @Query(sort: \Recipe.name, order: .forward) 
  var recipes: [Recipe]

  var body: some View {
    NavigationView {
      List {
        ForEach(recipes) { recipe in
          NavigationLink(recipe.name, destination: RecipeView(recipe))
        }
        .onDelete(perform: deleteRecipes)
      }
    }
    .toolbar {
      ToolbarItem(placement: .navigationBarTrailing) {
        EditButton()
      }
      ToolbarItem {
        Button(action: addRecipe) {
          Label("Add Recipe", systemImage: "plus")
        }
      }
    }
  }

  private func addRecipe() {
    withAnimation {
      let newRecipe = Recipe("New Recipe")
        modelContext.insert(newRecipe)
    }
  }

  private func deleteRecipes(offsets: IndexSet) {
    withAnimation {
      for index in offsets {
        modelContext.delete(recipes[index])
      }
    }
  }
}

If you’ve used Core Data in the past, you may have noticed there’s no call to context.save(). That’s right — because it’s no longer required. By default, SwiftData will autosave your context to the store on a state change in the UI or after a certain time period. You’re free to call save() if you wish, though.