Modern SwiftUI Architecture
Concise entry point for pure SwiftUI architecture without ViewModels. Use the references for patterns, examples, and edge cases.
Scope
This skill covers SwiftUI views and their data flow.
When a request asks you to restructure UIKit or AppKit code, say that this guidance does not cover that code. Leave its internals alone. Answer at the boundary instead:
- Wrap an existing view controller in
UIViewControllerRepresentableto show it in SwiftUI. - Wrap an existing view in
UIViewRepresentableonly when aUIViewis already separated from its controller. - Use
UIHostingControllerfor the opposite direction, to show a SwiftUI view inside UIKit. - On macOS, use
NSViewControllerRepresentable,NSViewRepresentable, andNSHostingControllerin the same three roles.
Keep the wrapped code's state where it already lives. Pass the SwiftUI side's values into the wrapper. Send the wrapper's events back out through a callback.
Start Here
- State management:
references/state-management.md - Observable services:
references/observable-patterns.md - Async work:
references/async-patterns.md - Navigation:
references/navigation-patterns.md - UI components:
references/lists.md,references/scrollview.md,references/forms.md,references/grids.md,references/sheets.md,references/tabs.md - Anti-patterns:
references/anti-patterns.md
Typical Flow
- Give every mutable value and dependency one owner and lifetime (
references/state-management.md).- Keep a value in
@Statein the view that uses it. - Lift it to the nearest common ancestor when sibling views read it. Pass it down as an argument or a
@Binding. - Move it into a shared
@Observableservice only when it must outlive that ancestor, when it is domain state that unrelated features read, or when passing it down would thread it through views that do not use it. A value in a service takes the service's lifetime, so it stays set after the screen closes. - Retain each shared service once with
@State. Inject each service separately through the environment. Mutate shared state through actions on the service.
- Keep a value in
- Keep views declarative and business logic in the injected services (
references/observable-patterns.md). - Give every async path one owner and one lifetime (
references/async-patterns.md).- Use
.taskfor work that starts with the view. - Use
.task(id:)for work that must restart when an input changes. SwiftUI cancels the running task when that identity changes and when the view disappears. Put any debounce inside that task. - Start no input-driven work from
onChangewith an unstructuredTask. - For work a user triggers, hold the
Taskin@State. Cancel the previous one before you start the next. State what happens to an in-flight task when the view disappears. - Tie each result to the input that produced it, so a superseded result never renders as the current one.
- Check
Task.isCancelledimmediately before every write to view state that follows an await, because a cancelled task keeps running until it checks. - Treat cancellation as a non-error outcome. Let a retry re-enter the same path.
- Use
- Wire navigation with
NavigationStackand typed destinations. Followreferences/navigation-patterns.mdwhen the project already depends on the AppRouter package. - Build UI using the matching component guides. Audit every decision against
references/anti-patterns.md.
Finish when every value and service has one stated owner, data flows down and actions up, and every async path has explicit cancellation and terminal-state behavior.