secana/Forji
3
54
Fork
You've already forked Forji
7

Proposal: incremental MVVM migration with @Observable #41

Open
opened 2026年06月02日 20:28:38 +02:00 by systemblue · 4 comments
Contributor
Copy link

Following up on #35, where you mentioned preferring an MVVM pass over TCA — and specifically that you'd want to do it properly rather than ending up with half the app on one paradigm and half on another.

Here's a concrete plan for migrating Forji to MVVM incrementally, one feature at a time, with the app working in mixed state throughout.

The pattern

Each feature gets a @MainActor @Observable view model that owns the feature's state and async work. The view becomes a thin binding layer. No new dependencies — just Swift's Observation framework, async/await, and protocols you already have.

Before (current pattern in NotificationsOverviewView)

struct NotificationsOverviewView: View {
 @State private var pagination = PaginationState<NotificationThread>()
 @State private var errorMessage: String?
 @State private var showError = false
 
 var body: some View {
 List { /* ... */ }
 .task { await loadNotifications() }
 }
 
 private func loadNotifications() async {
 do {
 let threads = try await notificationService.list(...)
 pagination.items = threads
 } catch {
 errorMessage = error.localizedDescription
 showError = true
 }
 }
 
 private func markAsRead(id: Int64) async {
 try? await notificationService.markAsRead(id: id)
 pagination.items.removeAll { 0ドル.id == id }
 NotificationCenter.default.post(name: .notificationsDidChange, object: nil)
 }
}

After (MVVM with @Observable)

@MainActor @Observable
final class NotificationsViewModel {
 private(set) var threads: [NotificationThread] = []
 private(set) var isLoading = false
 var error: Error?
 
 private let service: NotificationServiceProtocol
 
 init(service: NotificationServiceProtocol) {
 self.service = service
 }
 
 func load() async {
 isLoading = true
 defer { isLoading = false }
 do {
 threads = try await service.list(...)
 } catch {
 self.error = error
 }
 }
 
 func markAsRead(_ thread: NotificationThread) async {
 try? await service.markAsRead(id: thread.id)
 threads.removeAll { 0ドル.id == thread.id }
 }
}
struct NotificationsOverviewView: View {
 @State private var viewModel: NotificationsViewModel
 
 init(service: NotificationServiceProtocol) {
 _viewModel = State(initialValue: NotificationsViewModel(service: service))
 }
 
 var body: some View {
 List { /* bind to viewModel.threads */ }
 .task { await viewModel.load() }
 }
}

What changes

  • State, validation, and async work move to the view model. The view just binds.
  • NotificationCenter broadcasts disappear — parent views observe child view model state directly.
  • Each view model is unit-testable against a protocol-based fake service, no UI required.
  • @Observable (Observation framework) instead of ObservableObject — matches Apple's current direction and avoids the @Published boilerplate.

Migration order

Notifications first — it has the clearest service boundary and the mark-as-read fix from #33 already touched these paths. Then one feature at a time: repositories, issues, pull requests. Each PR is self-contained. Unmigrated features keep working as-is.

Offer

Happy to ship PR 1 (Notifications view model extraction) whenever you're ready. No rush — just wanted to lay out what the concrete shape looks like so you can evaluate it.

Following up on #35, where you mentioned preferring an MVVM pass over TCA — and specifically that you'd want to do it properly rather than ending up with half the app on one paradigm and half on another. Here's a concrete plan for migrating Forji to MVVM incrementally, one feature at a time, with the app working in mixed state throughout. ## The pattern Each feature gets a `@MainActor @Observable` view model that owns the feature's state and async work. The view becomes a thin binding layer. No new dependencies — just Swift's Observation framework, async/await, and protocols you already have. ### Before (current pattern in NotificationsOverviewView) ```swift struct NotificationsOverviewView: View { @State private var pagination = PaginationState<NotificationThread>() @State private var errorMessage: String? @State private var showError = false var body: some View { List { /* ... */ } .task { await loadNotifications() } } private func loadNotifications() async { do { let threads = try await notificationService.list(...) pagination.items = threads } catch { errorMessage = error.localizedDescription showError = true } } private func markAsRead(id: Int64) async { try? await notificationService.markAsRead(id: id) pagination.items.removeAll { 0ドル.id == id } NotificationCenter.default.post(name: .notificationsDidChange, object: nil) } } ``` ### After (MVVM with @Observable) ```swift @MainActor @Observable final class NotificationsViewModel { private(set) var threads: [NotificationThread] = [] private(set) var isLoading = false var error: Error? private let service: NotificationServiceProtocol init(service: NotificationServiceProtocol) { self.service = service } func load() async { isLoading = true defer { isLoading = false } do { threads = try await service.list(...) } catch { self.error = error } } func markAsRead(_ thread: NotificationThread) async { try? await service.markAsRead(id: thread.id) threads.removeAll { 0ドル.id == thread.id } } } ``` ```swift struct NotificationsOverviewView: View { @State private var viewModel: NotificationsViewModel init(service: NotificationServiceProtocol) { _viewModel = State(initialValue: NotificationsViewModel(service: service)) } var body: some View { List { /* bind to viewModel.threads */ } .task { await viewModel.load() } } } ``` ## What changes - State, validation, and async work move to the view model. The view just binds. - `NotificationCenter` broadcasts disappear — parent views observe child view model state directly. - Each view model is unit-testable against a protocol-based fake service, no UI required. - `@Observable` (Observation framework) instead of `ObservableObject` — matches Apple's current direction and avoids the `@Published` boilerplate. ## Migration order Notifications first — it has the clearest service boundary and the mark-as-read fix from #33 already touched these paths. Then one feature at a time: repositories, issues, pull requests. Each PR is self-contained. Unmigrated features keep working as-is. ## Offer Happy to ship PR 1 (Notifications view model extraction) whenever you're ready. No rush — just wanted to lay out what the concrete shape looks like so you can evaluate it.
Author
Contributor
Copy link

Really like what you have built with Forji, and we want to be a regular contributor going forward, so flag any issues or PRs you would like a hand with and we will jump in.

Really like what you have built with Forji, and we want to be a regular contributor going forward, so flag any issues or PRs you would like a hand with and we will jump in.
Author
Contributor
Copy link

One small thing while we're here: the per-PR copy-paste grant works but adds a little friction now that a few of us are contributing. If it's ever useful, we're happy to help set up a one-time version (a DCO sign-off or a short CLA), or fold it into the CONTRIBUTING.md from #42 so it's not repeated per PR. Totally your call, the GPLv3 + App Store exception setup makes sense to us.

One small thing while we're here: the per-PR copy-paste grant works but adds a little friction now that a few of us are contributing. If it's ever useful, we're happy to help set up a one-time version (a DCO sign-off or a short CLA), or fold it into the CONTRIBUTING.md from #42 so it's not repeated per PR. Totally your call, the GPLv3 + App Store exception setup makes sense to us.
Author
Contributor
Copy link

PR 1 (the Notifications view model extraction) is built and staged on our fork: protocol seam, view model, and unit tests against a fake service, full suite green with behavior unchanged. Ready to send whenever you want it.

PR 1 (the Notifications view model extraction) is built and staged on our fork: protocol seam, view model, and unit tests against a fake service, full suite green with behavior unchanged. Ready to send whenever you want it.
Author
Contributor
Copy link

Two more views that would benefit from this pattern:

  • PullRequestDetailView.swift — 612 lines
  • RepositoryDetailView.swift — 598 lines (already has swiftlint:disable file_length)

Happy to start with PullRequestDetailView as the next slice after the notifications view model lands.

Two more views that would benefit from this pattern: - `PullRequestDetailView.swift` — 612 lines - `RepositoryDetailView.swift` — 598 lines (already has `swiftlint:disable file_length`) Happy to start with `PullRequestDetailView` as the next slice after the notifications view model lands.
Sign in to join this conversation.
No Branch/Tag specified
main
v1.6
v1.5
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
secana/Forji#41
Reference in a new issue
secana/Forji
No description provided.
Delete branch "%!s()"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?