body. Keep simple, always-true facts on the model, move the rest into a plain struct plus a calculator type, and store the computed result in @State so it runs once per input change instead of on every render.Why this happens
When you drop MVVM, the ViewModel’s 21 properties and 9 functions have to go somewhere. The tempting move is to push everything into an extension on the managed object, because then the view reads like Text(goal.requiredMonthlySaving). The view gets shorter, but you have not actually split the work — you have just moved it out of sight.
The real problem is two-fold. First, a computed property on a NSManagedObject looks free but is not: reading goal.transactions can fire a fault and pull a relationship into memory every time it is touched, and SwiftUI re-evaluates body often. Second, most of those nine functions are not facts about a goal at all. Chart series, filtered transaction lists, max expense, month projections — those describe one screen’s report, not the entity.
So the fix is not “model vs view model”. It is separating three things that got mixed together: the entity’s own values, the reporting calculations, and the UI state (picker selections, filters).
How to fix it
1. Keep only intrinsic facts on the entity
A small extension is fine when the value is genuinely part of the object and cheap to derive — remaining amount, days until the planned date. No layout, no formatting, no filtering for a specific screen.
import Foundation
import CoreData
extension Goal {
// Adjust the casts to whatever type your generated subclasses use.
var remainingAmount: Decimal {
amount.decimalValue - (saving?.decimalValue ?? 0)
}
var daysUntilCompletion: Int {
Calendar.current.dateComponents(
[.day], from: Date(), to: plannedCompletionDate
).day ?? 0
}
}
Note what is not here: requiredMonthlySaving, maxExpenseAmount, grouping, or anything that iterates transactions. Those belong to the next step.
2. Move reporting logic into a value type plus a calculator
Define a struct that holds exactly what the statistics screen displays, and a type that produces it. This keeps the expensive work in one testable place and makes it obvious that it is a snapshot, not a live property.
struct GoalStatistics {
let remainingAmount: Decimal
let daysUntilCompletion: Int
let requiredMonthlySaving: Decimal
let maxExpenseAmount: Decimal
let expenseCount: Int
}
struct GoalStatisticsCalculator {
func calculate(
for goal: Goal,
onlyExpenses: Bool = false,
now: Date = .now,
calendar: Calendar = .current
) -> GoalStatistics {
let days = calendar.dateComponents(
[.day], from: now, to: goal.plannedCompletionDate
).day ?? 0
let remaining = goal.remainingAmount
let months = max(Double(days) / 30.0, 1.0)
let monthly = remaining > 0 && days >= 0
? remaining / Decimal(months)
: 0
let all = (goal.transactions as? Set<Transaction>) ?? []
let expenses = all.filter { $0.transactionType == .expense }
let maxExpense = expenses.map { $0.amount.decimalValue }.max() ?? 0
return GoalStatistics(
remainingAmount: remaining,
daysUntilCompletion: days,
requiredMonthlySaving: monthly,
maxExpenseAmount: onlyExpenses ? maxExpense : maxExpense,
expenseCount: expenses.count
)
}
}
Passing now and calendar as parameters is what makes this testable — you can assert on a fixed date instead of hoping the test runs on the right day.
3. Cache the result in @State, don’t compute in body
This is the part that actually protects performance. Compute once when the screen appears or when an input changes, store the snapshot, and let the view read a plain struct.
struct GoalStatisticsView: View {
@ObservedObject var goal: Goal
@State private var statistics: GoalStatistics?
@State private var showExpensesOnly = false
private let calculator = GoalStatisticsCalculator()
var body: some View {
List {
if let statistics {
LabeledContent("Remaining", value: "\(statistics.remainingAmount)")
LabeledContent("Per month", value: "\(statistics.requiredMonthlySaving)")
LabeledContent("Largest expense", value: "\(statistics.maxExpenseAmount)")
LabeledContent("Expenses", value: "\(statistics.expenseCount)")
}
}
.task { refresh() }
.onChange(of: showExpensesOnly) { _, newValue in
refresh(onlyExpenses: newValue)
}
}
private func refresh(onlyExpenses: Bool = false) {
statistics = calculator.calculate(for: goal, onlyExpenses: onlyExpenses)
}
}
If a value must stay live while the user edits, call refresh() from the save path (onSubmit, a toolbar button) rather than from body. .task also cancels correctly when the view disappears, which .onAppear does not.
4. Keep fetching separate from calculating
Filtering goal.transactions in memory is fine for tens of objects and wrong for thousands. When the set grows, let Core Data do the filtering with a @FetchRequest scoped to the goal, or a small fetch helper, and hand the calculator an array:
@FetchRequest private var expenses: FetchedResults<Transaction>
init(goal: Goal) {
_expenses = FetchRequest(
sortDescriptors: [SortDescriptor(\.date, order: .reverse)],
predicate: NSPredicate(
format: "goal == %@ AND transactionType == %d",
goal, TransactionType.expense.rawValue
)
)
}
Then change the calculator to accept [Transaction] instead of reaching into the relationship. One caveat: predicated fetches with @FetchRequest need the store to be set up and the object to be in a context, so construct this only from a view that already has the goal.
5. Keep picker and filter state in the view
@State for showExpensesOnly, selected date range, chosen category — that is view state, not model state. Do not store it on the entity, and do not add it as a property on the calculator. When it changes, recompute the snapshot. That single rule removes most of what made the old ViewModel large.
How to prevent it
- Before adding a computed property to a
NSManagedObjectsubclass, ask whether it makes sense outside this one screen. If not, it goes in a calculator or a value type. - Treat
bodyas read-only for cost: no filtering, sorting, mapping, or relationship traversal inside it. - Never walk a to-many relationship from a computed property that the view reads repeatedly; it may fire a fault on every access.
- Return a struct snapshot from expensive work so the result is explicit, cacheable and comparable in tests.
- Inject
DateandCalendarinstead of callingDate()andCalendar.currentdeep inside calculations. - If you are leaving MVVM, split the ViewModel’s responsibilities — don’t relocate them wholesale into the model.
FAQ
Should I delete the ViewModel entirely when moving to vanilla SwiftUI?
If it is 21 properties and 9 functions, no. Split it: entity facts to a small extension, reporting math to a calculator, selection and filtering to @State, and fetching to @FetchRequest or a fetch helper. What is left over is usually tiny, and you can delete that.
Is a computed property on a Core Data entity actually slow?
By itself, no — arithmetic on already-loaded values is cheap. The cost comes from what the property touches: relationship traversal can fault in objects, and SwiftUI calls the property every time it re-renders the view that reads it. Cache the result and the question disappears.
Isn’t GoalStatisticsCalculator just a ViewModel with a different name?
It is narrower. It has no published state, no lifecycle, and no reference to any view — it takes a Goal (or an array of transactions) and returns a struct. That makes it trivial to unit test and safe to call from anywhere, including a background task or a widget timeline.
Where should the transaction filtering go?
Use a predicate fetch when the collection can grow, and in-memory filtering only for small, already-loaded sets. The calculator should receive a plain [Transaction] so it never has to decide how the data was retrieved.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
