Appearance
Managing Data with Core Data: CRUD Operations
Last Updated: Dec 19, 2024
INFO
Resources:
- Apple's documentation - Core Data: https://developer.apple.com/documentation/coredata/
- CoreDataExample on GitHub: https://github.com/CarolaneLFBV/Tutorials
In the previous article, we implemented the Core Data stack and created our firstTodo
entity with its id
, title
, and isDone
attributes. Now, let's bring Core Data to life by managing data dynamically in our app. In this article, we will explore the four core operations of working with data: Create, Read, Update and Delete
—commonly referred to as CRUD.
Using Core Data in SwiftUI, you will learn how to add new tasks, fetch and display them, toggle their completion status, and delete them when no longer needed. Ready? Let's dive in!
Setting Up the Environment
INFO
In this article, we will follow the MVVM design pattern for implementing CRUD operations. The operations will be encapsulated in a Repository, which will handle all communication with Core Data. The ViewModel will act as a mediator between the Repository and the SwiftUI View, ensuring clean and modular code.
If you're unfamiliar with MVVM, I recommend checking out my Design Pattern - Structural Patterns
article, specifically the Model-View-ViewModel (MVVM) section.
Before we begin, make sure you have followed the previous article and that your Core Data stack is set up.
When you are done, let's start by implementing our PersistentController
into our app. You need to go to your App file, and add your persistentController into your first view that is going to be loaded when the app is launched:
swift
import SwiftUI
@main
struct CoreDataExampleApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.viewContext)
}
}
}
The .environment(\.managedObjectContext)
modifier injects the Core Data viewContext
into the SwiftUI environment. This allows all child views to access the context using @Environment(\.managedObjectContext)
, simplifying data flow and making Core Data operations seamless across your app. By adding it in the App
file, you ensure the context is available globally, eliminating the need to pass it manually between views.
WARNING
Little disclaimer before we continue, I use the terms reading
and fetching
interchangeably. Both refer to the same CRUD operation: Read
.
Now, let's create our Repository to communicate with Core Data. This repository will act as the middleman between Core Data and other parts of the app, such as a ViewModel for example, ensuring cleaner and more modular code. Create a new file, name it Repository
, and add the following:
swift
struct Crud {
private let viewContext = PersistenceController.shared.viewContext
enum Failed: Error {
case create(reason: String), delete(reason: String), update(reason: String)
}
// more to come
}
TIP
The Failed
enum allows us to handle errors in a clear and structured way. Instead of returning generic errors, we can specify the exact type of operation that failed (e.g., create
, delete
, update
). This makes debugging easier and improves code readability.
Here, we're calling the singleton PersistentController
we implemented in the previous article. This gives us access to the viewContext
, which is the main workspace for interacting with Core Data.
Next, we’ll implement the create
, delete
, and update
methods in this repository to dynamically interact with the Todo
entity.
Creating Data
In order to add new data into Core Data, we need our create
method. This method allows us to add a new Todo
item to the Core Data persistent store.
swift
struct Crud {
private let viewContext = PersistenceController.shared.viewContext
enum Failed: Error {
case create(reason: String), delete(reason: String), update(reason: String)
}
// New Creating Method
func create(title: String) throws {
let newTodo = Todo(context: viewContext)
newTodo.id = UUID()
newTodo.title = title
newTodo.isDone = false
do {
try viewContext.save()
} catch {
throw Failed.create(reason: error.localizedDescription)
}
}
}
- A new
Todo
object is initialized using the Core DataviewContext
. TheviewContext
ensures that the new object is managed by Core Data. - We then set default values:
id
is a uniqueUUID
that is assigned to identify each task.- The method accepts a
title
parameter, which is passed when creating a task. isDone
defaults tofalse
since tasks are incomplete when created.
- After the new object is added to the
viewContext
, thesave()
method persists the changes to Core Data. If an error occurs, the customFailed.create
error is thrown with a description of the issue.
How to Use the Create Method
Next, we'll use this method in our ViewModel
, which acts as the intermediate layer between the repository and the view. Here's how it looks:
swift
import SwiftUI
@Observable
class TodoViewModel {
private let repository: Crud
var todos: [Todo] = []
init(repository: Crud = Crud()) {
self.repository = repository
}
func addTodo(title: String) {
do {
try repository.create(title: title)
} catch {
print("Error adding Todo: \(error)")
}
}
}
By injecting the Crud
repository into the TodoViewModel
, we make the ViewModel more flexible and testable. For example. during testing, you could provide it a mock repository, so it is easier and more manageable.
The repository.create
method is called with the title
passed by the view.
TIP
Little reminder that in production, consider showing an error message to the user if creating a Todo
fails. This improves the user experience and makes your app more robust.
Connecting the View
To allow users to add new Todo
items through the UI, we create an AddTodoView
that interacts with the TodoViewModel
. Here’s the code:
swift
import SwiftUI
struct AddTodoView: View {
@State var viewModel: TodoViewModel
@State private var newTodoTitle: String = ""
var body: some View {
HStack {
TextField("New Todo", text: $newTodoTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: addTodo) {
Label("Add", systemImage: "plus")
}
}
.padding()
}
private func addTodo() {
guard !newTodoTitle.isEmpty else { return }
withAnimation {
viewModel.addTodo(title: newTodoTitle)
newTodoTitle = ""
}
}
}
The TextField
is bound the newTodoTitle
state variable, which tracks the user's input. Then, the button triggers the addTodo
private method when pressed. The addTodo
method ensures first of all that it isn't empty before proceeding. Then, it passes the newTodoTitle
to the addTodo
method in the TodoViewModel
, which then calls the repository's create
method. The withAnimation
block allows you to add a smooth transitions in the UI when the list of Todos updates.
When the new item has been successfully added, the function clears the TextField
.
Now, let's implement our view into our ContentView
:
swift
import SwiftUI
struct ContentView: View {
@State private var viewModel = TodoViewModel()
var body: some View {
NavigationView {
VStack {
// more to come
AddTodoView(viewModel: viewModel)
}
}
}
}
We are initializing our ViewModel in the parent view, and then passes it to our child view so we can dynamically use the same data everywhere.
Reading Data
To read data from Core Data, we use the @FetchRequest
property wrapper provided by SwiftUI. It’s a powerful tool that fetches data directly from the Core Data store and keeps the UI in sync with any changes.
swift
@FetchRequest(
entity: Todo.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Todo.title, ascending: true)]
) private var todos: FetchedResults<Todo>
- We first specifies the Core Data entity (
Todo
) that we want to fetch. This links theFetchRequest
to theTodo
model we created before. - Then, we use the
sortDescriptors
to fetch results alphabetically by thetitle
attribute. We can use multipleNSSortDescriptor
s to define complex sorting rules. - And we store the result of the fetch into our
todos
variable. TheFetchedResults
object acts like an array, containing all theTodo
objects retrieved from Core Data. The UI automatically updates whenever the data in Core Data changes, ensuring seamless synchronization.
The @FetchRequest
property wrapper ensures that your view always displays the latest data from Core Data. When you create, update, or delete a Todo
, the fetch request automatically reflects those changes in the UI without requiring manual refreshes.
Updating Data
The update
method allows us to modify an existing Todo
in Core Data. In this case, it toggles the isDone
property of the Todo
item, marking it as complete or incomplete. Let’s see how it works.
swift
import SwiftUI
struct Crud {
private let viewContext = PersistenceController.shared.viewContext
enum Failed: Error {
case create(reason: String), delete(reason: String), update(reason: String)
}
func create(title: String) throws {
let newTodo = Todo(context: viewContext)
newTodo.id = UUID()
newTodo.title = title
newTodo.isDone = false
do {
try viewContext.save()
} catch {
throw Failed.create(reason: error.localizedDescription)
}
}
// New Update Method
func update(_ todo: Todo) throws {
todo.isDone.toggle()
do {
try viewContext.save()
} catch {
throw Failed.update(reason: error.localizedDescription)
}
}
}
- Togging
isDone
attribute: Thetoggle()
method flips the value ofisDone
betweentrue
andfalse
. This lets us mark a task as completed or not with a single action. - Saving the Changes: Once the
isDone
attribute is updated, thesave()
method ensures the changes are persisted to Core Data. - Error Handling: If saving the changes fails, the method throws our custom
Failed.update
error with a detailed reason, ensuring errors are handled gracefully.
How to Use the Update Method
The toggleIsDone
method in the TodoViewModel
calls the repository’s update
method. Here’s how it works:
swift
import SwiftUI
@Observable
class TodoViewModel {
private let repository: Crud
var todos: [Todo] = []
init(repository: Crud = Crud()) {
self.repository = repository
}
func addTodo(title: String) {
do {
try repository.create(title: title)
} catch {
print("Error adding Todo: \(error)")
}
}
// Update CRUD operation
func toggleIsDone(_ todo: Todo) {
do {
try repository.update(todo)
} catch {
print("Error toggling isDone: \(error)")
}
}
}
We need to tell that our repository takes a todo
object in the function, since we are updating its isDone
data in the repository.
Updating the Update Method in SwiftUI
To display a list of Todo
items and toggle their completion status, we use the TodoListView
. This view fetches data from Core Data, displays it in a List
, and updates tasks dynamically:
swift
import SwiftUI
struct TodoListView: View {
@State var viewModel: TodoViewModel
@FetchRequest(
entity: Todo.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Todo.title, ascending: true)]
) private var todos: FetchedResults<Todo>
var body: some View {
List {
ForEach(todos) { todo in
HStack {
Text(todo.title ?? "Untitled")
.strikethrough(todo.isDone, color: .red)
.foregroundColor(todo.isDone ? .gray : .primary)
Spacer()
Button(action: {
toggleIsDone(todo)
}) {
Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle")
.foregroundColor(todo.isDone ? .green : .gray)
}
}
}
}
}
private func toggleIsDone(_ todo: Todo) {
withAnimation {
viewModel.toggleIsDone(todo)
}
}
}
- The
List
view iterates through thetodos
array provided by theFetchRequest
and displays eachTodo
. - We add some UI details such as:
strikethrough
: Visually indicates completed tasks by striking through their text in red.foregroundColor
: Uses gray text for completed tasks and the default primary color for active tasks.
- Our button calls the
toggleIsDone
method, passing theTodo
item as an argument. Depending on the task being completed or not, it display an icon. - Then, the function delegates the update logic to the
toggleIsDone
method in theTodoViewModel
, with a smooth animation.
Now, let's add our view into the ContentView
:
swift
import SwiftUI
struct ContentView: View {
@State private var viewModel = TodoViewModel()
var body: some View {
NavigationView {
VStack {
TodoListView(viewModel: viewModel) // here
AddTodoView(viewModel: viewModel)
}
}
}
}
Deleting Data
The final CRUD operation is deleting data. In order to remove tasks from Core Data, we use the delete
method in our repository:
swift
import SwiftUI
struct Crud {
private let viewContext = PersistenceController.shared.viewContext
enum Failed: Error {
case create(reason: String), delete(reason: String), update(reason: String)
}
func create(title: String) throws {
let newTodo = Todo(context: viewContext)
newTodo.id = UUID()
newTodo.title = title
newTodo.isDone = false
do {
try viewContext.save()
} catch {
throw Failed.create(reason: error.localizedDescription)
}
}
func update(_ todo: Todo) throws {
todo.isDone.toggle()
do {
try viewContext.save()
} catch {
throw Failed.update(reason: error.localizedDescription)
}
}
// New Delete Method
func delete(_ todo: Todo) throws {
viewContext.delete(todo)
do {
try viewContext.save()
} catch {
throw Failed.delete(reason: error.localizedDescription)
}
}
}
- This method marks the
Todo
object for deletion within the Core Data context. - Then, the changes are persisted to the Core Data store with
save()
. If you skip this step, the object won't be removed from the database. - To finish, if the save operation fails, an error is thrown with a our custom error.
Connecting to the ViewModel
The deleteTodo
method in the TodoViewModel
calls the repository's delete
method:
swift
import SwiftUI
@Observable
class TodoViewModel {
private let repository: Crud
var todos: [Todo] = []
init(repository: Crud = Crud()) {
self.repository = repository
}
func addTodo(title: String) {
do {
try repository.create(title: title)
} catch {
print("Error adding Todo: \(error)")
}
}
func toggleIsDone(_ todo: Todo) {
do {
try repository.update(todo)
} catch {
print("Error toggling isDone: \(error)")
}
}
// Delete CRUD Operation
func deleteTodo(_ todo: Todo) {
do {
try repository.delete(todo)
} catch {
print("Error deleting Todos: \(error)")
}
}
}
Same as the update method, we need to precise the todo
object in the method, since we need to know which item must be removed.
Integrating with the View
swift
import SwiftUI
struct TodoListView: View {
@State var viewModel: TodoViewModel
@FetchRequest(
entity: Todo.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Todo.title, ascending: true)]
) private var todos: FetchedResults<Todo>
var body: some View {
List {
ForEach(todos) { todo in
HStack {
Text(todo.title ?? "Untitled")
.strikethrough(todo.isDone, color: .red)
.foregroundColor(todo.isDone ? .gray : .primary)
Spacer()
Button(action: {
toggleIsDone(todo)
}) {
Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle")
.foregroundColor(todo.isDone ? .green : .gray)
}
}
// here
.swipeActions {
Button(role: .destructive) {
deleteTodo(todo)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
private func toggleIsDone(_ todo: Todo) {
withAnimation {
viewModel.toggleIsDone(todo)
}
}
// Delete function fron ViewModel
private func deleteTodo(_ todo: Todo) {
withAnimation {
viewModel.deleteTodo(todo)
}
}
}
- With
swipeActions
we can now swipe any item in the list, and display an icon such astrash
. - The
role: .destructive
parameter styles the button to indicate it performs a destructive action (e.g., deleting). - The
deleteTodo
method delegates the deletion to theTodoViewModel
, which then interacts with the repository.
Conclusion
In this article, we’ve covered the complete CRUD operations using Core Data in a modular and user-friendly way. From creating tasks to updating their status, reading them dynamically, and finally deleting them, you’ve learned how to interact with Core Data in SwiftUI while adhering to clean architecture principles.
Here’s a quick recap of what we achieved:
- Created Data: Added new
Todo
tasks with default values. - Read Data: Fetched and displayed tasks in a dynamic SwiftUI list using
@FetchRequest
. - Updated Data: Toggled the
isDone
state of tasks to mark them as complete or incomplete. - Deleted Data: Removed tasks using swipe-to-delete gestures, with changes reflected instantly in the UI.
By separating concerns across the Repository, ViewModel, and View, you’ve seen how to build a scalable and testable app structure. The use of animations, intuitive UI components, and Core Data’s powerful persistence features ensures both great performance and an excellent user experience.