← All topics/Architecture & design patterns

Question 6

Single source of truth and duplicated state

Users see different counts on the list and detail screens after an edit. Architecturally, how do you prevent multiple 'caches of truth' for the same entity?

Follow-ups

  • What about SwiftUI @State vs shared store?

Answer outline

Mismatched counts happen when the same entity lives in multiple mutable places. The list screen caches one copy, the detail screen another, and a write to one never reaches the other.

The fix is a single source of truth: one place owns the entity, every screen reads from it, and every write goes through it.

Separate canonical state from view-local state. Canonical state is the shared model that must agree across screens, such as a like count, a user profile, or a cart total. View-local state is scoped to one view and temporary, such as draft text, a loading flag, or whether a sheet is showing.

Once an edit is committed, it updates the shared store. Every view then derives its display from that store instead of holding its own copy.

Principles

  • Store each entity once, and have every screen read from that same source rather than a local copy.
  • Route every write through the store, so views never mutate shared data directly.
  • Keep canonical state (shared, must be consistent) apart from view-local state (temporary, scoped to one view).
  • @State fits view-local state, and entities that appear on more than one screen belong in a shared observable store.

The store owns the array, and views call update rather than mutating their own copies:

Shared store: single write point
@Observable
final class PostStore {
    private(set) var posts: [Post] = []

    func update(_ post: Post) {
        guard let index = posts.firstIndex(where: { $0.id == post.id })
        else { return }
        posts[index] = post   // one write, and every observer sees it
    }
}

Both views receive the same PostStore instance, and neither keeps its own copy of the data:

Two views, one store: counts stay in sync
struct PostListView: View {
    let store: PostStore

    var body: some View {
        List(store.posts) { PostRow(post: $0) }
    }
}

struct PostDetailView: View {
    let store: PostStore
    let postId: Post.ID

    var post: Post? { store.posts.first { $0.id == postId } }

    var body: some View {
        Text("Likes: \(post?.likeCount ?? 0)")
        Button("Like") {
            if var p = post {
                p.likeCount += 1
                store.update(p)   // the list view updates automatically
            }
        }
    }
}

Follow-up angles

  • @State is the right fit for sheet visibility, draft text, and selection. That state lives and dies with one view and doesn't need to agree with anything else.
  • For persistence-backed entities, let SwiftData or Core Data be the store and query from there. View models shouldn't cache the results in separate properties.