1

Definitely one of the stranger quirks of SwiftData I've come across.

I have a ScriptView that shows Line entities related to a Production, and a TextEnterScriptView that’s presented in a sheet to input text.

I’m noticing that every time I type in the TextEditor within TextEnterScriptView, a new Line shows up in ScriptView — even though I haven’t explicitly inserted it into the modelContext.

I'm quite confused because even though I’m only assigning a new Line to a local @State array in TextEnterScriptView, every keystroke in the TextEditor causes a duplicate Line to appear in ScriptView.

In other words, Why is SwiftData creating new Line entities every time I type in the TextEditor, even though I’m only assigning to a local @State array and not explicitly inserting them into the modelContext?

Here is my minimal reproducible example:

import SwiftData
import SwiftUI
@main
struct testApp: App {
 var body: some Scene {
 WindowGroup {
 ContentView()
 .modelContainer(for: Line.self, isAutosaveEnabled: false)
 }
 }
}
struct ContentView: View {
 @Environment(\.modelContext) var modelContext
 @Query(sort: \Production.title) var productions: [Production]
 var body: some View {
 NavigationStack {
 List(productions) { production in
 NavigationLink(value: production) {
 Text(production.title)
 }
 }
 .navigationDestination(for: Production.self) { production in
 ScriptView(production: production)
 }
 .toolbar {
 Button("Add", systemImage: "plus") {
 let production = Production(title: "Test \(productions.count + 1)")
 modelContext.insert(production)
 do {
 try modelContext.save()
 } catch {
 print(error)
 }
 }
 }
 .navigationTitle("Productions")
 }
 }
}
struct ScriptView: View {
 @Query private var lines: [Line]
 let production: Production
 @State private var isShowingSheet: Bool = false
 var body: some View {
 List {
 ForEach(lines) { line in
 Text(line.content)
 }
 }
 .toolbar {
 Button("Show Sheet") {
 isShowingSheet.toggle()
 }
 }
 .sheet(isPresented: $isShowingSheet) {
 TextEnterScriptView(production: production)
 }
 }
}
struct TextEnterScriptView: View {
 @Environment(\.dismiss) var dismiss
 @State private var text = ""
 @State private var lines: [Line] = []
 let production: Production
 var body: some View {
 NavigationStack {
 TextEditor(text: $text)
 .onChange(of: text, initial: false) {
 lines = [Line(content: "test line", production: production)]
 }
 .toolbar {
 Button("Done") {
 dismiss()
 }
 }
 }
 }
}
@Model
class Production {
 @Attribute(.unique) var title: String
 @Relationship(deleteRule: .cascade, inverse: \Line.production)
 var lines: [Line] = []
 init(title: String) {
 self.title = title
 }
}
@Model
class Line {
 var content: String
 var production: Production?
 init(content: String, production: Production?) {
 self.content = content
 self.production = production
 }
}
asked Apr 12, 2025 at 11:12
4
  • Yes, that’s what happens when you do Line(content: "test line", production: production). The line gets inserted because it has a relationship with the production, which has been inserted. What is your question? Commented Apr 12, 2025 at 11:35
  • Try onSubmit instead of onChange. Also you need a @Query with a filter for only the Lines in the Production. And you'll need some bindings to the model instead of state. Commented Apr 12, 2025 at 11:43
  • I was under the impression that lines weren't inserted until modelContext.insert(model) was called? Commented Apr 12, 2025 at 11:50
  • SwiftData does it for you and it must do it or the autosave functionality wouldn't work. Commented Apr 12, 2025 at 12:47

1 Answer 1

1

Yes, this can be confusing when you first face it. When you create a state that holds an object/instance of a SwiftData @Model that has relationships, it automatically gets added to the context.

In this case, Line has a relationship with Production so it gets automatically inserted into context, since a Production cannot have a relationship or reference anything NOT in the context.

To avoid this, you must work with objects not in state, similar to how you did it to create a new Production with default title:

//This won't get added to context automatically, since it's not assigned to a State
let production = Production(title: "Test \(productions.count + 1)") 

In your case, the solution is simple because you don't actually need a state that holds an array of lines [Line]. You actually just need a string state (see revised TextEnterScriptView in the full code below).

But in cases where you work with more complex models with many properties, it can be cumbersome to create a state for each property. In that case, you can create an intermediate struct that has a property of the respective type. See how it's done in the ProductionEditor view of the full code below.

So create a helper struct:

//Helper struct to create a Production object without it being added to the context automatically
private struct DraftProduction {
 var draft: Production = Production(title: "")
}

Now you can assign that struct to a state:

@State private var draftProduction = DraftProduction()

Then you can reference or create bindings to properties of the actual Production object via the intermediate struct state instance, without it being automatically inserted into context (since DraftProduction has no relationships with other models that are in the context):

//Binding to the Production's title property
TextField("Enter production name", text: $draftProduction.draft.title) 

To address your TextEnterScriptView view, a state to hold [Line] is not needed, because you most likely do not want to add a new line for every character you type in the text editor (which is what would happen based on the logic of the .onChange ).

So if a production has arrays of lines, the input view should add a single Line , meaning you just need a simple String state, which gets appended as a Line to the production's lines property as part of the view's save function:

private func save() {
 let newLine = Line(content: text, production: production)
 production.lines.append(newLine) 
}

Note that a context insert is not needed here, since as soon as you append to the production object that is already in context, the Line will have to be added to the context in order for the Production object to reference it.


Here's the full updated code:

import SwiftData
import SwiftUI
//Main view
struct ScriptContentView: View {
 
 //Queries
 @Query(sort: \Production.title) var productions: [Production]
 
 //Environment values
 @Environment(\.modelContext) var modelContext
 
 //State values
 @State private var showProductionEditorSheet = false
 
 //Body
 var body: some View {
 NavigationStack {
 List(productions) { production in
 NavigationLink(value: production) {
 Text(production.title)
 }
 }
 .contentMargins(.vertical, 20)
 
 //Overlay for empty productions content
 .overlay {
 if productions.isEmpty {
 ContentUnavailableView {
 Label("No productions", systemImage: "movieclapper")
 } description: {
 Text("Add productions by tapping the + button.")
 } actions: {
 addProductionButton
 }
 }
 }
 .navigationDestination(for: Production.self) { production in
 ScriptView(production: production)
 }
 .navigationTitle("Productions")
 .toolbar {
 
 //Add production button
 ToolbarItem {
 addProductionButton
 }
 
 //Reset context
 ToolbarItem(placement: .topBarLeading) {
 Button {
 withAnimation {
 try? modelContext.delete(model: Production.self)
 try? modelContext.save()
 }
 } label: {
 Label("Reset", systemImage: "arrow.trianglehead.counterclockwise")
 }
 }
 }
 .sheet(isPresented: $showProductionEditorSheet) {
 ProductionEditor(production: nil)
 .presentationDetents([.medium])
 }
 }
 }
 
 //Helper view so it can be referenced in multiple places
 private var addProductionButton: some View {
 Menu {
 Button("Default title", systemImage: "plus") {
 let production = Production(title: "Production \(productions.count + 1)")
 withAnimation {
 modelContext.insert(production)
 }
 do {
 try modelContext.save()
 } catch {
 print(error)
 }
 }
 
 Button("Custom title", systemImage: "plus") {
 showProductionEditorSheet.toggle()
 }
 } label : {
 Label("Add production", systemImage: "plus")
 }
 }
}
struct ScriptView: View {
 // @Query private var lines: [Line]
 
 //Parameters
 let production: Production
 
 //State values
 @State private var isShowingSheet: Bool = false
 
 //Body
 var body: some View {
 List {
 if !production.lines.isEmpty {
 Section("Lines") {
 ForEach(production.lines) { line in
 Text(line.content)
 }
 }
 }
 }
 .listRowSpacing(10)
 
 //Overlay for empty production lines content
 .overlay {
 if production.lines.isEmpty {
 ContentUnavailableView {
 Label("Empty script", systemImage: "text.document")
 } description: {
 Text("Add lines by tapping the Add Line button.")
 }
 }
 }
 .navigationTitle(production.title)
 .toolbarTitleDisplayMode(.inline)
 .toolbar {
 Button("Add line") {
 isShowingSheet.toggle()
 }
 }
 .sheet(isPresented: $isShowingSheet) {
 TextEnterScriptView(production: production)
 .presentationDetents([.medium, .large])
 }
 }
}
struct ProductionEditor: View {
 
 //Parameters
 let production: Production?
 
 //Queries
 
 //Environment values
 @Environment(\.dismiss) var dismiss
 @Environment(\.modelContext) var modelContext
 
 //State values
 @State private var draftProduction = DraftProduction()
 
 //Computed properties
 private var editorTitle: String {
 production == nil ? "Add production" : "Edit production"
 }
 
 //Body
 var body: some View {
 
 NavigationStack {
 Form {
 Section("Name") {
 TextField("Enter production name", text: $draftProduction.draft.title) // <- Binding to the draft Production's title property
 }
 }
 .navigationTitle(editorTitle)
 .toolbarTitleDisplayMode(.inline)
 .toolbar {
 
 //Add button
 ToolbarItem(placement: .primaryAction) {
 Button {
 withAnimation {
 save()
 dismiss()
 }
 } label: {
 Text("Add")
 }
 .disabled(draftProduction.draft.title.isEmpty)
 }
 
 //Cancel button
 ToolbarItem(placement: .topBarLeading) {
 Button {
 dismiss()
 } label: {
 Text("Cancel")
 }
 }
 }
 }
 }
 
 //Helper struct to create a Production object without it being added to the context automatically
 private struct DraftProduction {
 var draft: Production = Production(title: "")
 }
 
 //Save function
 private func save() {
 if production == nil { // Adding a production
 modelContext.insert(draftProduction.draft) // <- Insert the draft Production object
 }
 else { //Editing a production
 //....
 }
 
 try? modelContext.save()
 }
}
struct TextEnterScriptView: View {
 
 //Parameters
 let production: Production
 
 //Environment values
 @Environment(\.dismiss) var dismiss
 
 //State values
 @State private var text = ""
 
 //Body
 var body: some View {
 NavigationStack {
 Form {
 Section("Line content") {
 TextEditor(text: $text)
 .overlay(alignment: .topLeading) {
 //Show placeholder text if no text entered
 if text.isEmpty {
 Text("Add line content...")
 .foregroundStyle(.tertiary)
 .padding(.top, 8)
 .padding(.leading, 3)
 }
 }
 }
 }
 .toolbar {
 
 //Add button
 ToolbarItem(placement: .primaryAction) {
 Button("Add") {
 save()
 dismiss()
 }
 .disabled(text.isEmpty) //Disable Add button if text is empty
 }
 
 //Cancel button
 ToolbarItem(placement: .topBarLeading) {
 Button {
 dismiss()
 } label: {
 Text("Cancel")
 }
 }
 }
 }
 }
 
 //Save function
 private func save() {
 let newLine = Line(content: text, production: production) // <- the new line object can reference a production that is in context, because the line itself is not in the context
 
 production.lines.append(newLine) //Appending to production, which is already in context, will automatically insert the new line into context, since production cannot have a relationship or reference anything NOT in the context
 }
 
}
//Models
@Model
class Production {
 @Attribute(.unique) var title: String
 @Relationship(deleteRule: .cascade, inverse: \Line.production)
 var lines: [Line] = []
 init(title: String) {
 self.title = title
 }
}
@Model
class Line {
 var content: String
 var production: Production?
 init(content: String, production: Production?) {
 self.content = content
 self.production = production
 }
}
//Preview
#Preview {
 ScriptContentView()
 .modelContainer(for: [Line.self, Production.self], inMemory: true) // <- change inMemory to false if you want to persist changes
}

enter image description here

answered Apr 12, 2025 at 17:20
Sign up to request clarification or add additional context in comments.

5 Comments

Thank you, are you saying that even if I set production to nil, the lines are still inserted simply because they are marked with @Model?
Good question. Actually, if the line's production is nil, the line will not be inserted into context since there is no relationship really that needs to be fulfilled. I thought you are initializing with a nil value, but after reviewing, you are assigning a production. I updated my answer to remove that statement, to avoid confusion. However, with a nil production, you won't have a relation to the production, meaning you will have to loop over the lines array and set each line's production value, maybe as part of the save function. But it seems a little twisted to go about it like that.
@AndreiG. do you know if this auto-inserting behaviour is documented anywhere?
@lukas There is a mention in the documentation of ModelContext.
@lukas - From the link above: "If your app’s schema describes relationships between models, you don’t need to manually insert each model into the context when you first create them. Instead, create the graph of related models and insert only the graph’s root model into the context. The context recognizes the hierarchy and automatically handles the insertion of the related models. The same behavior applies even if the graph contains both new and existing models"

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.