Every pet app developer has felt that moment: the walk counter resets when you don't expect it, or the feeding schedule shows stale data after a background fetch. SwiftUI's state management tools are powerful, but they're also easy to misuse. This checklist walks you through the essential decisions—from @State to @EnvironmentObject—so your pet app stays reliable and your users' data stays intact.
Who needs this and what goes wrong without it
If you're building a SwiftUI app that tracks pet activities—walks, meals, vet visits—you're managing state that changes frequently and must persist across view updates. Without a solid state management strategy, you'll encounter bugs like views not updating when a pet's name changes, data loss during navigation, or crashes from deallocated observable objects.
Consider a typical scenario: a pet owner logs a walk, and the app updates the daily step count. If you use @State for the walk log, the data disappears when the view is dismissed. If you use @ObservedObject without proper ownership, the object might be recreated each time the view appears, causing flickering or lost entries. The problem isn't SwiftUI—it's choosing the wrong tool for each piece of state.
Common failure modes in pet apps
One pattern we see often is using @State for shared data like a pet's profile. @State is designed for local, simple values—strings, booleans, enums. When you pass it down to child views via @Binding, it works, but if you need to share the same data across multiple tabs or sheets, you'll end up with duplicate state. Another frequent issue is forgetting to mark the class as ObservableObject. Without that, @Published properties won't trigger view updates, and your UI will appear frozen.
We've also seen teams struggle with @EnvironmentObject when they inject it at the wrong level. For example, placing a PetStore environment object on a detail view instead of the root view means that when the user navigates back, the object is deallocated and recreated—losing all changes. The result is a frustrating experience for pet owners who have to re-enter data.
This checklist is for you if you've ever wondered: "Should this be @State or @StateObject?" "Why does my view not update when the data changes?" "How do I pass data between sibling views without spaghetti code?" We'll answer those questions with concrete rules and a reusable decision framework.
Prerequisites / context readers should settle first
Before diving into the checklist, make sure you have a solid grasp of SwiftUI's view lifecycle and the concept of source of truth. A source of truth is the single place where a piece of data is stored and mutated. In SwiftUI, each property wrapper defines a different ownership model: @State owns its value locally, @StateObject owns a reference type, @ObservedObject does not own the object, and @EnvironmentObject reads from the environment without owning it.
Understanding property wrapper roles
We'll briefly recap the main wrappers: @State for simple value types owned by the view; @Binding for a two-way connection to a @State owned elsewhere; @StateObject for reference types that the view creates and owns; @ObservedObject for reference types created elsewhere and passed in; @Published inside an ObservableObject to announce changes; and @EnvironmentObject for dependency injection from the environment. Each has a specific purpose, and using them interchangeably leads to bugs.
Another prerequisite is familiarity with SwiftUI's view struct and how they are recreated. Unlike UIKit, SwiftUI views are lightweight structs that are created and discarded frequently. That means any object stored directly in a view (like a class instance held by @State) must be managed carefully to avoid recreation loops. @StateObject ensures the object is created once and persists for the view's lifetime, while @ObservedObject does not—so if you use @ObservedObject on a view that gets recreated, the object is recreated too.
Setting up a test harness
We recommend creating a small test app with a pet model: a Pet class with @Published properties for name, species, and lastWalkDate. Then build a simple UI with a list and detail view. This sandbox will let you experiment with each property wrapper and see the behavior firsthand. You'll need Xcode 14+ and a target running iOS 16+ to follow along with the latest SwiftUI features, but the principles apply to earlier versions as well.
Finally, understand that state management is not a one-size-fits-all solution. The same app might use @State for a text field, @StateObject for a view model, and @EnvironmentObject for a shared data store. The key is to match the wrapper to the data's scope and lifecycle. With that foundation, you're ready for the workflow.
Core workflow (sequential steps in prose)
This workflow helps you decide which property wrapper to use for any given piece of state. Follow these steps in order, and you'll avoid most common mistakes.
Step 1: Identify the data's scope
Ask: Is this data used only by one view, or by multiple views? If it's local to one view (like a text field entry or a toggle state), use @State. If it needs to be shared with child views, use @Binding to pass it down. If it's shared across unrelated views (like a pet's profile that appears in both the list and detail), you need a shared reference type—either @StateObject (if the view creates it) or @EnvironmentObject (if it's injected from higher up).
Step 2: Decide if the data is a value type or reference type
For simple values (String, Int, Bool, enum), @State is ideal. For complex data that includes multiple properties or methods, use a class conforming to ObservableObject. If you need to observe changes to a class, use @StateObject or @ObservedObject, never @State (which expects a value type).
Step 3: Determine ownership
Who creates the object? If the view itself creates it (e.g., in an initializer or as a default value), use @StateObject. If the object is created elsewhere and passed in (e.g., from a parent view or a data layer), use @ObservedObject. For environment objects, the view doesn't own it—it reads from the environment, so use @EnvironmentObject.
Step 4: Test the lifecycle
Once you've chosen a wrapper, test the view's lifecycle: dismiss and reappear the view, navigate back and forth, and check if the data persists correctly. If the data resets unexpectedly, you might have used @ObservedObject where @StateObject was needed, or you might have injected the environment object at the wrong level.
Step 5: Verify thread safety
SwiftUI expects all UI updates to happen on the main thread. If you update @Published properties from a background queue (e.g., after a network call), wrap the assignment in DispatchQueue.main.async. Use the @MainActor attribute on your ObservableObject class to enforce this automatically.
We've found that following these five steps consistently eliminates about 80% of state-related bugs. The remaining 20% come from edge cases like nested navigation or multi-window support, which we cover in the variations section.
Tools, setup, or environment realities
Your development environment plays a role in state management. Xcode's SwiftUI previews are great for iterating on UI, but they have limitations: previews don't always respect environment objects correctly, and they may recreate view models on each refresh. To test state reliably, we recommend running the app on a simulator or device.
Debugging state changes
SwiftUI provides several tools to inspect state. Use the view inspector (the eye icon in the debug area) to see the current values of @State and @StateObject. You can also add print statements inside the ObservableObject's didSet or use Combine's sink to log changes. For complex apps, consider using a logging framework like OSLog to trace state updates without cluttering the console.
Setting up a shared data store
For pet apps, a common pattern is to have a central PetStore class that holds an array of pets and methods to add, update, or delete them. Inject this store as an @EnvironmentObject at the root of your app. Then any view can access it via @EnvironmentObject var store: PetStore. This avoids passing it through every view's initializer.
One gotcha: when using @EnvironmentObject, you must inject it before any view tries to read it. If you forget to call .environmentObject() on a parent view, the app will crash with a runtime error. To prevent this, we recommend writing a unit test that creates the root view and checks that no environment object is missing.
Testing state-driven UI
Write unit tests for your ObservableObject classes. Test that updating a @Published property triggers the expected UI change. Use XCTestExpectation to wait for async updates. For example, if a PetStore's fetchPets() method updates a @Published array, test that the array's count changes after the method completes. This catches issues like missing @MainActor or incorrect publisher usage.
We also suggest using SwiftUI's PreviewProvider with sample data. Create a mock PetStore that returns predefined pets, and use it in your previews. This ensures your views work with realistic state before you run the full app.
Variations for different constraints
Not every pet app has the same architecture. Here are variations for common constraints.
Single-view apps vs. multi-tab apps
In a simple single-view app (e.g., a pet weight tracker), @State and @StateObject may be sufficient. But as soon as you add a second tab (e.g., a walk log), you need shared state. Use @EnvironmentObject for the shared data store. If you use @StateObject in each tab, each tab will have its own copy of the data, leading to inconsistencies.
For multi-tab apps, we recommend creating the shared store in the App struct and passing it via .environmentObject(). Then each tab's root view can access it. This ensures that when the user switches tabs, they see the same data.
Apps with Core Data or CloudKit
If you use Core Data, your managed object context is the source of truth. Use @FetchRequest or @SectionedFetchRequest to observe changes. For CloudKit sync, consider using NSPersistentCloudKitContainer and let Core Data handle the sync. In this case, you still need a view model to perform business logic, but the state is managed by Core Data's faulting and refresh mechanisms.
One common pitfall is trying to use @StateObject with a Core Data managed object. Don't do that. Managed objects are reference types, but they are not ObservableObject by default. Instead, use @ObservedObject with a view model that wraps the managed object, or use SwiftUI's @FetchRequest directly in the view.
Apps with complex navigation (e.g., navigation stack with sheets)
When you have a navigation stack with multiple sheets and detail views, state can become tangled. Use @StateObject for view models that are scoped to a navigation path. For example, if you have a PetDetailView that shows a sheet to edit the pet's name, the sheet should receive the pet model as an @ObservedObject (or @Binding if it's a simple value). Avoid passing the entire store into every sheet; instead, pass only the relevant data.
For deep navigation, consider using a coordinator pattern with an ObservableObject that manages the navigation state. This keeps your views clean and makes state changes predictable.
Pitfalls, debugging, what to check when it fails
Even with a checklist, things can go wrong. Here are the most common pitfalls and how to fix them.
Pitfall 1: Using @State for a class
If you write @State var pet: PetClass, SwiftUI will not observe changes to the class's properties because @State expects a value type. The UI will never update. Fix: change to @StateObject if the view creates the pet, or @ObservedObject if it's passed in.
Pitfall 2: Forgetting @Published
You create an ObservableObject class but forget to add @Published to the properties that should trigger UI updates. The properties change, but the UI stays the same. Fix: add @Published to any property that, when changed, should cause the view to redraw.
Pitfall 3: Multiple sources of truth
You have the same data stored in two different places—e.g., a @State in the parent and a @StateObject in the child. When the child updates its copy, the parent doesn't know. Fix: choose one source of truth. If the data belongs to the parent, use @State and pass a @Binding to the child. If the data belongs to a shared model, use @EnvironmentObject.
Pitfall 4: Environment object not injected
You use @EnvironmentObject in a view but forget to call .environmentObject() on a parent. The app crashes with a message like "No ObservableObject of type PetStore found." Fix: ensure the environment object is injected at the highest level needed, usually the App struct or the root NavigationView.
Debugging steps
When your pet app's state behaves unexpectedly, follow these steps: 1) Check the console for SwiftUI warnings about invalidated views. 2) Use the view inspector to see the current state values. 3) Add a breakpoint inside the ObservableObject's didSet to see when properties change. 4) Verify that the object's lifecycle matches your expectations—use deinit to log when it's deallocated. 5) Reduce the problem to a minimal reproducible example; often the act of isolating the bug reveals the cause.
We've compiled a quick reference checklist: (1) Is the data local or shared? (2) Value or reference type? (3) Who owns it? (4) Is @Published applied? (5) Is the environment injected? (6) Are updates on the main thread? Run through these six questions whenever you encounter a state bug, and you'll resolve most issues in minutes.
Now go apply this checklist to your next pet app feature. Start with a single view, test thoroughly, and then expand. Your users—and their pets—will thank you for the reliability.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!