<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>swiftyplace</title>
	<atom:link href="https://www.swiftyplace.com/feed" rel="self" type="application/rss+xml" />
	<link>https://www.swiftyplace.com</link>
	<description>Learn how to build amazing apps with SwiftUI and Combine</description>
	<lastBuildDate>Sun, 08 Nov 2026 10:16:41 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://www.swiftyplace.com/wp-content/uploads/2023/08/cropped-logo-1-32x32.png</url>
	<title>swiftyplace</title>
	<link>https://www.swiftyplace.com</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>MVC, MVP, MVVM, VIPER Which Ones Matter for SwiftUI?</title>
		<link>https://www.swiftyplace.com/blog/architecture-comparison-swiftui?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=architecture-comparison-swiftui</link>
					<comments>https://www.swiftyplace.com/blog/architecture-comparison-swiftui#respond</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Sat, 07 Nov 2026 11:25:50 +0000</pubDate>
				<category><![CDATA[SwiftUI Architecture]]></category>
		<category><![CDATA[SwiftUI Components]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005732</guid>

					<description><![CDATA[<p>If you&#8217;ve done any iOS development, you&#8217;ve heard these acronyms. They come up in SwiftUI architecture discussions, in code reviews, and in almost every job interview. And for a long time, I just accepted them ... <a title="MVC, MVP, MVVM, VIPER Which Ones Matter for SwiftUI?" class="read-more" href="https://www.swiftyplace.com/blog/architecture-comparison-swiftui" aria-label="More on MVC, MVP, MVVM, VIPER Which Ones Matter for SwiftUI?">Read more</a></p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/architecture-comparison-swiftui">MVC, MVP, MVVM, VIPER Which Ones Matter for SwiftUI?</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">If you&#8217;ve done any iOS development, you&#8217;ve heard these acronyms. They come up in SwiftUI architecture discussions, in code reviews, and in almost every job interview. And for a long time, I just accepted them as the patterns you&#8217;re supposed to know.</p>



<p class="wp-block-paragraph">When I moved to SwiftUI, I started wondering how much of this still applies. Most of these patterns were created for UIKit, some even for completely different platforms. SwiftUI works very differently. So I went through them one by one and asked myself what problem each pattern was solving, and whether I still have that problem in SwiftUI.</p>



<p class="wp-block-paragraph">This post is what I came up with (my personal take that can also vary depending on the individual project requirements).</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What iOS Architecture Patterns Have in Common</h2>



<p class="wp-block-paragraph">Before comparing them, it helps to look at what they have in common. Every one of these patterns is a way of sorting your code into buckets.</p>



<p class="wp-block-paragraph">Think of a wardrobe. You could throw all your clothes onto one pile, but getting dressed would be painful. So you sort them: one shelf for shirts, one for pants, a drawer for socks. Architecture patterns do the same thing for code. They give you a place for UI, a place for state and logic, and a place for data.</p>


<div class="gb-container gb-container-314e24b3">

<figure class="gb-block-image gb-block-image-363d67f0"><img fetchpriority="high" decoding="async" width="1256" height="827" class="gb-image gb-image-363d67f0" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/code-organization.webp" alt="organizing a swiftui app in different components" title="code-organization" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/code-organization.webp 1256w, https://www.swiftyplace.com/wp-content/uploads/2026/10/code-organization-300x198.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/code-organization-1024x674.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/code-organization-768x506.webp 768w" sizes="(max-width: 1256px) 100vw, 1256px" /></figure>

</div>


<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">When I compared the patterns, I noticed that they all have a model and a view. The differences are in the layers between them, and in what I call the plumbing: how those layers talk to each other. Delegates, NotificationCenter, Combine, or bindings built into the framework. </p>



<p class="wp-block-paragraph">That plumbing turned out to be the key to figuring out which patterns still matter.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">MVC (Model-View-Controller): Why SwiftUI Doesn&#8217;t Need a Controller</h2>



<p class="wp-block-paragraph">MVC was Apple&#8217;s pattern for UIKit, and it&#8217;s built around the <code>UIViewController</code>. The controller sits between the model and the view and does most of the plumbing. It connects outlets, listens for data changes, and tells the table view to reload.</p>



<p class="wp-block-paragraph">The problem with MVC is well known. The controller tends to collect everything, and you end up with what people jokingly call the &#8220;Massive View Controller.&#8221;</p>



<p class="wp-block-paragraph">When I looked at MVC from a SwiftUI perspective, I noticed that the controller&#8217;s main job doesn&#8217;t exist anymore. SwiftUI updates the UI automatically when your state changes. There are no outlets to connect and no table view to reload. The framework does the work the controller used to do.</p>



<p class="wp-block-paragraph">So for SwiftUI, MVC isn&#8217;t really relevant anymore.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">MVP (Model-View-Presenter)</h2>



<p class="wp-block-paragraph">MVP replaces the controller with a presenter. The presenter holds the logic, and the view stays passive: it only displays what the presenter tells it to display. In UIKit this was popular for testability, since you could test the presenter without any UI.</p>



<p class="wp-block-paragraph">But MVP is also mostly about plumbing. The presenter pushes updates into the view manually. In SwiftUI, the view reads state and updates itself, so that manual connection is exactly what SwiftUI removed.</p>



<p class="wp-block-paragraph">I don&#8217;t see a reason to use MVP in SwiftUI either.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">VIPER: Too Much Ceremony for Most SwiftUI Apps</h2>



<p class="wp-block-paragraph">VIPER splits each screen into five layers: view, interactor, presenter, entity, and a layer for navigation. It was designed for large UIKit apps and comes with a lot of ceremony. Every layer has protocols, and there are strict rules for how the layers connect.</p>



<p class="wp-block-paragraph">I spent a bit longer on VIPER, because it does solve real problems. In big teams, that ceremony gives consistency: every screen is built the same way, and everyone knows where things go. That&#8217;s valuable.</p>



<p class="wp-block-paragraph">But when I looked at how much of that ceremony exists to manage UIKit&#8217;s plumbing, the answer was: a lot. In SwiftUI, much of it becomes overhead. For most SwiftUI apps, VIPER is too heavy.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">MVVM (Model-View-ViewModel): The Natural Fit for SwiftUI</h2>



<p class="wp-block-paragraph">MVVM was created by John Gossman at Microsoft in 2005 for WPF. It splits a screen into three buckets: the model for the data, the view for the UI, and the view model as the layer backing the UI, holding its state and logic.</p>



<p class="wp-block-paragraph">That&#8217;s how I use it in SwiftUI. A view can handle simple state and logic on its own, like a toggle with <code>@State</code> or a quick action on a button tap. But when the logic gets too complex, with network calls, loading and error handling, and state that changes in several ways, it doesn&#8217;t belong in the view anymore. That&#8217;s the point where I move it out into a view model that backs the UI, and the view goes back to layout and forwarding user actions. SwiftUI makes this split easy, because with <code>@Observable</code> the view reads the view model&#8217;s properties and updates automatically, without any extra plumbing.</p>



<p class="wp-block-paragraph">As long as the logic in a view stays simple, I keep it there. I add a view model when the complexity calls for it, not by default for every screen.</p>



<figure class="gb-block-image gb-block-image-0ac911b0"><img decoding="async" width="1863" height="1056" class="gb-image gb-image-0ac911b0" src="http://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing.webp" alt="viewmodel as the ui backing layer for swiftui apps" title="Presentation Layer - Backing" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing.webp 1863w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing-300x170.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing-1024x580.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing-768x435.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing-1536x871.webp 1536w" sizes="(max-width: 1863px) 100vw, 1863px" /></figure>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">MVC vs MVP vs MVVM vs VIPER: Side-by-Side Comparison</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Pattern</th><th>Layers</th><th>Made for</th><th>Relevance in SwiftUI</th></tr></thead><tbody><tr><td>MVC</td><td>Model, View, Controller</td><td>UIKit</td><td>Low, SwiftUI does the controller&#8217;s job</td></tr><tr><td>MVP</td><td>Model, View, Presenter</td><td>UIKit</td><td>Low, SwiftUI removes the manual plumbing</td></tr><tr><td>MVVM</td><td>Model, View, ViewModel</td><td>WPF, later UIKit</td><td>High, SwiftUI has binding built in</td></tr><tr><td>VIPER</td><td>Five layers</td><td>Large UIKit apps</td><td>Low, too much ceremony</td></tr></tbody></table></figure>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What Belongs in a SwiftUI View Model</h2>



<p class="wp-block-paragraph">Once the logic is complex enough to move out of the view, the next question is what exactly goes into the view model. For a while, my view models collected everything: state, networking, caching, formatting, navigation. They became the SwiftUI version of the Massive View Controller.</p>



<p class="wp-block-paragraph">So I thought about <a href="https://www.swiftyplace.com/blog/swiftui-mvvm" data-type="post" data-id="1005742">what the view model is really for</a>, and I landed on two jobs.</p>



<p class="wp-block-paragraph"><strong>The view model holds UI state.</strong> That&#8217;s everything the view reads to render itself: the list of products, a loading flag, an error message.</p>



<p class="wp-block-paragraph"><strong>The view model changes that state.</strong> User actions come in from the view: a tap, a pull to refresh, a toggle. The view model reacts and updates its state. The view never changes the state itself. It tells the view model what happened and then renders whatever the state looks like.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class ProductListViewModel {
    private(set) var products: &#91;Product&#93; = []
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    private let service: ProductService

    init(service: ProductService) {
        self.service = service
    }

    func loadProducts() async {
        isLoading = true
        defer { isLoading = false }

        do {
            products = try await service.fetchProducts()
        } catch {
            errorMessage = "Could not load products."
        }
    }

    func toggleFavorite(for product: Product) {
        guard let index = products.firstIndex(where: { $0.id == product.id }) else { return }
        products&#91;index&#93;.isFavorite.toggle()
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductListViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> products: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> errorMessage: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> service: ProductService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">service</span><span style="color: #F6F6F4">: ProductService) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.service </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> service</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">true</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">defer</span><span style="color: #F6F6F4"> { isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">do</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            products </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> service.</span><span style="color: #97E1F1">fetchProducts</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">catch</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            errorMessage </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Could not load products.</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleFavorite</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">product</span><span style="color: #F6F6F4">: Product) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">guard</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> index </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> products.</span><span style="color: #97E1F1">firstIndex</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">where</span><span style="color: #F6F6F4">: { </span><span style="color: #BF9EEE; font-style: italic">$0</span><span style="color: #F6F6F4">.id </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> product.id }) </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">        products&#91;index&#93;.isFavorite.</span><span style="color: #97E1F1">toggle</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The view stays simple. It reads the state and forwards user actions:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ProductListView: View {
    let viewModel: ProductListViewModel

    var body: some View {
        List(viewModel.products) { product in
            ProductRow(product: product) {
                viewModel.toggleFavorite(for: product)
            }
        }
        .overlay {
            if viewModel.isLoading { ProgressView() }
        }
        .task { await viewModel.loadProducts() }
        .refreshable { await viewModel.loadProducts() }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductListView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> viewModel: ProductListViewModel</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">List</span><span style="color: #F6F6F4">(viewModel.products) { product </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">ProductRow</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">product</span><span style="color: #F6F6F4">: product) {</span></span>
<span class="line"><span style="color: #F6F6F4">                viewModel.</span><span style="color: #97E1F1">toggleFavorite</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: product)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">overlay</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> viewModel.isLoading { </span><span style="color: #97E1F1">ProgressView</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">task</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel.</span><span style="color: #97E1F1">loadProducts</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">refreshable</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel.</span><span style="color: #97E1F1">loadProducts</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">From these two jobs, I got my rule: <strong>if the UI doesn&#8217;t read it, it doesn&#8217;t belong in the view model.</strong></p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Where Networking and Data Access Go: The Service Layer</h3>



<p class="wp-block-paragraph">That rule immediately raised the next question: where does everything else go?</p>



<p class="wp-block-paragraph">The view model doesn&#8217;t fetch, decode, or save anything itself. It calls a service and assigns the result to its state. Services handle data access: networking, persistence, authentication.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>protocol ProductService: Sendable {
    func fetchProducts() async throws -> &#91;Product&#93;
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">protocol</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">And this is where I noticed that MVVM stops describing my app. Classic MVVM has three layers: model, view, and view model. Real apps need more than that. Below the view model, I have services for data access, and sometimes separate types for states that the UI never shows, like a token store. Using <a href="https://www.swiftyplace.com/blog/dependency-injection-swiftui" data-type="link" data-id="https://www.swiftyplace.com/blog/dependency-injection-swiftui">dependency injection</a> (Dependency Inversion Principle) helps with testing and loose coupling.</p>



<p class="wp-block-paragraph">None of that is in the original pattern. It&#8217;s what I needed to keep each layer focused on one concern.</p>



<p class="wp-block-paragraph"></p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">SwiftUI Navigation with Coordinators: Deep Links and Programmatic Navigation</h3>



<p class="wp-block-paragraph">Navigation was the last piece that didn&#8217;t fit anywhere.</p>



<p class="wp-block-paragraph">Here&#8217;s an example that made this clear to me. Say your app is connected to a server, and the user suddenly gets a bonus you want to show in a sheet. Who checks the server? Who presents the sheet? Which view? Which view model? If you put it into one of the existing view models, it&#8217;s in the wrong place. The bonus has nothing to do with the product list that happens to be on screen.</p>



<p class="wp-block-paragraph">The same happens with deep links. When a link opens a specific product, something has to switch to the right tab, push the right screens, and maybe dismiss a sheet that&#8217;s currently open. No single view model owns all of that.</p>



<p class="wp-block-paragraph">So I added coordinators. The way I see it, navigation is UI state too: the current tab, the navigation path, the presented sheet. The view reads it to decide what to show. So a coordinator is an <code>@Observable</code> type on the main actor, just like a view model, but its concern is navigation:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>enum ShopDestination: Hashable {
    case productDetail(Product.ID)
    case cart
}

@MainActor
@Observable
final class ShopCoordinator {
    var path: &#91;ShopDestination&#93; = []
    var presentedBonus: Bonus?

    func showProduct(_ id: Product.ID) {
        path.append(.productDetail(id))
    }

    func showCart() {
        path.append(.cart)
    }

    func showBonus(_ bonus: Bonus) {
        presentedBonus = bonus
    }

    func popToRoot() {
        path.removeAll()
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">enum</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ShopDestination</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Hashable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> productDetail(Product.ID)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> cart</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ShopCoordinator</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> path: &#91;ShopDestination&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> presentedBonus: Bonus</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">showProduct</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">id</span><span style="color: #F6F6F4">: Product.ID) {</span></span>
<span class="line"><span style="color: #F6F6F4">        path.</span><span style="color: #97E1F1">append</span><span style="color: #F6F6F4">(.</span><span style="color: #97E1F1">productDetail</span><span style="color: #F6F6F4">(id))</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">showCart</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        path.</span><span style="color: #97E1F1">append</span><span style="color: #F6F6F4">(.cart)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">showBonus</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">bonus</span><span style="color: #F6F6F4">: Bonus) {</span></span>
<span class="line"><span style="color: #F6F6F4">        presentedBonus </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> bonus</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">popToRoot</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        path.</span><span style="color: #97E1F1">removeAll</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The view binds its <code>NavigationStack</code> to the coordinator&#8217;s path, and navigating becomes a function call instead of something buried in a view.</p>



<p class="wp-block-paragraph">For larger apps, I nest coordinators. Each feature has its own coordinator for its own navigation, and an app coordinator sits on top. It owns the child coordinators and handles everything that crosses features, like deep links:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class AppCoordinator {
    var selectedTab: Tab = .shop
    let shop = ShopCoordinator()
    let profile = ProfileCoordinator()

    func handle(_ deepLink: DeepLink) {
        switch deepLink {
        case .product(let id):
            selectedTab = .shop
            shop.popToRoot()
            shop.showProduct(id)
        case .settings:
            selectedTab = .profile
            profile.showSettings()
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">AppCoordinator</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedTab: Tab </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .shop</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> shop </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ShopCoordinator</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProfileCoordinator</span><span style="color: #F6F6F4">()</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">handle</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">deepLink</span><span style="color: #F6F6F4">: DeepLink) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> deepLink {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">product</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> id)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">            selectedTab </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .shop</span></span>
<span class="line"><span style="color: #F6F6F4">            shop.</span><span style="color: #97E1F1">popToRoot</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">            shop.</span><span style="color: #97E1F1">showProduct</span><span style="color: #F6F6F4">(id)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .settings</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">            selectedTab </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .profile</span></span>
<span class="line"><span style="color: #F6F6F4">            profile.</span><span style="color: #97E1F1">showSettings</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">A deep link comes in, the app coordinator switches the tab, and the shop coordinator pushes the product. Programmatic navigation works the same way: whoever needs to navigate calls the coordinator. For the bonus example, a service checks the server, and the coordinator presents the sheet. Each part does one job.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">&#8220;No View Model&#8221; Approaches</h3>



<p class="wp-block-paragraph">Reading SwiftUI architecture discussions online can be confusing. One person says you don&#8217;t need view models. Another uses stores. A third puts everything in services. Each one sounds like a different architecture, and you start wondering which one you&#8217;re supposed to learn.</p>



<p class="wp-block-paragraph">Then I look at the code and realize: it&#8217;s the same thing I already know, just with a different name. Here&#8217;s how I map these approaches back to familiar terms, so you can understand what someone means and move on.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">1. &#8220;The view is the view model&#8221;</h4>



<p class="wp-block-paragraph">The first group says the view itself is the view model. State lives in&nbsp;<code>@State</code>, the logic sits in the view, and there is no extra class.</p>



<p class="wp-block-paragraph">That&#8217;s a valid <strong>choice</strong>. I do the same for simple screens. If you want to keep the logic in the view, you can.</p>



<p class="wp-block-paragraph">The trade-off shows up when the screen gets complex. Network calls, loading, error handling and state that changes in several ways all end up in&nbsp;<code>body</code>&nbsp;and its helper functions. The view gets long, and the logic is hard to test without the UI.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">2. &#8220;No view model&#8221;, but a store</h4>



<p class="wp-block-paragraph">Many people who say &#8220;no view models&#8221; introduce a store once their screens get complex. Look at what that store is:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Observable
final class ProductStore {
    private(set) var products: &#91;Product&#93; = []
    private(set) var isLoading = false

    func loadProducts() async { ... }
    func toggleFavorite(for product: Product) { ... }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductStore</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> products: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">...</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleFavorite</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">product</span><span style="color: #F6F6F4">: Product) { </span><span style="color: #F286C4">...</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Now compare it to the view model from earlier in this post:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Observable
final class ProductListViewModel {
    private(set) var products: &#91;Product&#93; = []
    private(set) var isLoading = false

    func loadProducts() async { ... }
    func toggleFavorite(for product: Product) { ... }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductListViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> products: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">...</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleFavorite</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">product</span><span style="color: #F6F6F4">: Product) { </span><span style="color: #F286C4">...</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">It&#8217;s the same type with a different name. An <code>@Observable</code> class that holds the state the UI reads and has methods the view calls. That is a view model.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">3. A real service, but more code in the view</h4>



<p class="wp-block-paragraph">Others show a service that is injected with dependency injection and only wraps the network client:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>protocol ProductService: Sendable {
    func fetchProducts() async throws -> &#91;Product&#93;
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">protocol</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">That is a real service in my book, and it&#8217;s exactly what I use below my view models. It holds no UI state.</p>



<p class="wp-block-paragraph">But then the loading flag, the error message, the product list and the logic that ties them together have to live somewhere. Without a view model, they go into the view. So this is case 1 again, with more complex code in the view.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">4. Called a service, but it holds UI state</h4>



<p class="wp-block-paragraph">The last variant is also called a service. But it&#8217;s an&nbsp;<code>@Observable</code>&nbsp;class with&nbsp;<code>isLoading</code>,&nbsp;<code>errorMessage</code>&nbsp;and the list the view displays, plus methods the view calls on user actions.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Observable
final class ProductsService {
    private(set) var products: &#91;Product&#93; = []
    private(set) var isLoading = false

    func loadProducts() async { ... }
    func toggleFavorite(for product: Product) { ... }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductsService</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> products: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">...</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleFavorite</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">product</span><span style="color: #F6F6F4">: Product) { </span><span style="color: #F286C4">...</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<p class="wp-block-paragraph">That&#8217;s a view model again. Usually it&#8217;s a view model that also does the networking, so it mixes UI state and data access in one type. It&#8217;s the same mix that turned view controllers into Massive View Controllers.</p>



<div style="height:37px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">My advice: look at the code, not the name</h4>



<figure class="wp-block-table"><table class="has-fixed-layout"><tbody><tr><td>What they show</td><td>What it really is</td></tr><tr><td>Logic in the view, no extra class</td><td>No view model. Fine for simple screens.</td></tr><tr><td>A &#8220;store&#8221; with UI state and methods</td><td>A view model</td></tr><tr><td>A service that only wraps the network client</td><td>A real service, plus a heavier view</td></tr><tr><td>A &#8220;service&#8221; with UI state and methods</td><td>A view model that also does data access</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">If it&#8217;s an&nbsp;<code>@Observable</code>&nbsp;class that holds UI state and has methods the view calls, it&#8217;s a view model, whatever it&#8217;s called.</p>



<p class="wp-block-paragraph">I know not everyone will like this classification. Some people see &#8220;store&#8221; as its own pattern. But a new name doesn&#8217;t make a new architecture. What matters is the concern of each type: UI state in one place, data access in another. If you call your view model a store, that&#8217;s fine. If your store also does the networking, that&#8217;s the real problem.</p>



<p class="wp-block-paragraph">That&#8217;s my advice for reading about architecture online: don&#8217;t lose your mind over every new name. Look at the code, map it to what you already know, and move on. We don&#8217;t need to reinvent the wheel and give it a fancy acronym when the existing terms already describe it.</p>



<p class="wp-block-paragraph">Not everyone will agree with this classification, and that&#8217;s fine. The name matters less than the concern of each type: UI state in one place, data access in another.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why I Don&#8217;t Follow One Architecture Pattern Strictly</h2>



<p class="wp-block-paragraph">After going through all of this, I realized I can&#8217;t really give my approach a clean name. It&#8217;s MVVM at its core, with services below and coordinators for navigation, and it doesn&#8217;t follow every rule of the original pattern.</p>



<p class="wp-block-paragraph">When I discussed this with other developers, someone described Apple&#8217;s own approach as &#8220;soft MVVM.&#8221; At first, calling a pattern &#8220;soft&#8221; sounded strange to me. But it describes pretty well what I do too: take the idea, keep the parts that solve my problems, and leave out the rest. For a simple screen that just displays some data, I don&#8217;t even add a view model.</p>



<p class="wp-block-paragraph">I think that&#8217;s the healthier way to look at patterns. Someone made them up for a specific framework at a specific time. MVVM was created for WPF 20 years ago, and SwiftUI will keep changing too. What matters is the concern of each layer, not following the pattern&#8217;s name to the letter.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why You Should Still Know Them All</h2>



<p class="wp-block-paragraph">That doesn&#8217;t mean you can skip MVC, MVP, and VIPER. You should still be able to explain all of them:</p>



<ul class="wp-block-list">
<li><strong>Communication.</strong> When you mention MVVM or VIPER, other developers immediately understand what you mean.</li>



<li><strong>Documentation.</strong> Pattern names make your codebase easier to explain.</li>



<li><strong>Job interviews.</strong> You&#8217;ll be asked about them, and you need to explain what they are and how they differ.</li>



<li><strong>Legacy code.</strong> Many apps still have UIKit parts built with MVC or VIPER, and you might work with them.</li>
</ul>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">How I Structure SwiftUI Apps</h2>



<ul class="wp-block-list">
<li><strong>Move logic into a view model when it gets too complex for the view.</strong> Simple state and logic can stay in the view. Network calls, loading and error handling, and complex state changes belong in a view model.</li>



<li><strong>Keep the view model for UI state.</strong> It holds the state the view reads and changes it in response to user actions. If the UI doesn&#8217;t read it, it doesn&#8217;t belong there.</li>



<li><strong>Move data work into services.</strong> Networking, persistence, and authentication go below the view model.</li>



<li><strong>Use coordinators for navigation.</strong> Treat navigation as UI state, and nest coordinators as your app grows, so deep links and programmatic navigation have one clear place.</li>



<li><strong>Know the other patterns.</strong> Understand MVC, MVP, and VIPER well enough to explain them and work with them in older code.</li>



<li><strong>Add layers when your code needs them.</strong> Not because a pattern tells you to.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>If you want to go deeper into how to structure your SwiftUI apps, from views and view models to services and coordinators, check out my course.</em></p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph"></p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/architecture-comparison-swiftui">MVC, MVP, MVVM, VIPER Which Ones Matter for SwiftUI?</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/architecture-comparison-swiftui/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Dependency Injection in SwiftUI: Initializer, Environment, or Container</title>
		<link>https://www.swiftyplace.com/blog/dependency-injection-swiftui?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=dependency-injection-swiftui</link>
					<comments>https://www.swiftyplace.com/blog/dependency-injection-swiftui#respond</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Thu, 05 Nov 2026 14:49:41 +0000</pubDate>
				<category><![CDATA[SwiftUI]]></category>
		<category><![CDATA[SwiftUI Architecture]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005797</guid>

					<description><![CDATA[<p>Where does a SwiftUI view get its dependencies from? Compare the initializer, the environment, and dependency injection containers like Factory, with the trade-offs of each.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/dependency-injection-swiftui">Dependency Injection in SwiftUI: Initializer, Environment, or Container</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Dependency injection means the view model gets its dependencies from outside, and whoever creates the view model decides which service to pass.  In SwiftUI, that &#8220;whoever&#8221; is usually a view. So where does the view get the service from, and how can a preview give it a mock instead?</p>



<p class="wp-block-paragraph">In this post, I&#8217;ll show how dependency injection in SwiftUI works as an app grows, with one example that goes from a single screen to a full app. Passing the service through the view&#8217;s initializer works for a single screen, but breaks down once the screen is used inside other views. That&#8217;s why I put services into the SwiftUI environment, which also makes previews and UI tests easier.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Starting Simple: Passing the Service Through the View&#8217;s Initializer</h2>



<p class="wp-block-paragraph">First, the setup. It&#8217;s the same as in Dependency Injection in Swift: a protocol for the service, a live implementation, a mock, and a view model that gets the service through its initializer. You can read how to set this up in more detail in this <a href="https://www.swiftyplace.com/blog/dependency-injection-swift">blog post</a>.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated protocol ProductService: Sendable {
    func fetchProducts() async throws -> &#91;Product&#93;
}

nonisolated struct LiveProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> &#91;Product&#93; { /* request, decode */ }
}

nonisolated struct MockProductService: ProductService {
    var products: &#91;Product&#93; = []

    func fetchProducts() async throws -> &#91;Product&#93; { products }
}

@MainActor
@Observable
final class ProductViewModel {
    private(set) var state: LoadingState&lt;&#91;Product&#93;> = .idle
    private let productService: ProductService

    init(productService: ProductService) {
        self.productService = productService
    }

    func load() async {
        state = .loading
        do {
            state = .loaded(try await productService.fetchProducts())
        } catch {
            state = .failed("Couldn't load products.")
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">protocol</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">LiveProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ProductService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@concurrent</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93; { </span><span style="color: #7B7F8B">/* request, decode */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">MockProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ProductService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> products: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93; { products }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> state: LoadingState&lt;&#91;Product&#93;&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .idle</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> productService: ProductService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">productService</span><span style="color: #F6F6F4">: ProductService) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.productService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> productService</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">load</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        state </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .loading</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">do</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            state </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">loaded</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> productService.</span><span style="color: #97E1F1">fetchProducts</span><span style="color: #F6F6F4">())</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">catch</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            state </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">failed</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Couldn&#39;t load products.</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">We start with a single screen that shows the products. The view creates its view model in <code>@State</code>, and the service is written right there:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ProductsScreen: View {
    @State private var viewModel = ProductViewModel(productService: LiveProductService())

    var body: some View {
        ProductListView(state: viewModel.state)
            .task { await viewModel.load() }
    }
}

#Preview {
    ProductsScreen() // calls the real API
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductsScreen</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">LiveProductService</span><span style="color: #F6F6F4">())</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">state</span><span style="color: #F6F6F4">: viewModel.state)</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">task</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProductsScreen</span><span style="color: #F6F6F4">() </span><span style="color: #7B7F8B">// calls the real API</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The view model itself is fine: it takes a protocol in its initializer. But the view hard-codes which service to pass, so the preview always calls the real API. Without a network connection, it shows an error; with a slow one, a spinner.</p>



<p class="wp-block-paragraph">The fix is the same idea one level up: the view gets the service through its own initializer. The live service is the default, so nothing changes for the rest of the app:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ProductsScreen: View {
    @State private var viewModel: ProductViewModel

    init(productService: ProductService = LiveProductService()) {
        _viewModel = State(initialValue: ProductViewModel(productService: productService))
    }

    var body: some View {
        ProductListView(state: viewModel.state)
            .task { await viewModel.load() }
    }
}

#Preview {
    ProductsScreen(productService: MockProductService(products: Product.samples))
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductsScreen</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel: ProductViewModel</span></span>
<span class="line"></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">productService</span><span style="color: #F6F6F4">: ProductService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">LiveProductService</span><span style="color: #F6F6F4">()) {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">        _viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">State</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">initialValue</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: productService))</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">state</span><span style="color: #F6F6F4">: viewModel.state)</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">task</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4"> {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProductsScreen</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples))</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<ul class="wp-block-list">
<li><strong>The app keeps calling <code>ProductsScreen()</code>.</strong> Only the preview passes the mock.</li>



<li><strong><code>_viewModel = State(initialValue:)</code></strong> sets the initial value of the <code>@State</code> property from inside <code>init</code>. The underscore accesses the property wrapper itself instead of the wrapped value.</li>



<li><strong>The initial value is only used the first time.</strong> SwiftUI keeps the first view model for as long as the view is on screen. If the parent later re-creates <code>ProductsScreen</code> with a different service, the new one is ignored.</li>



<li><strong>Keep the view model&#8217;s <code>init</code> cheap.</strong> Every time the parent re-creates the <code>ProductsScreen</code> struct, this <code>init</code> runs and creates a new <code>ProductViewModel</code>, which SwiftUI then throws away. That&#8217;s fine as long as the initializer only stores its dependencies and doesn&#8217;t start any work.</li>
</ul>



<p class="wp-block-paragraph">This works as long as the screen is only previewed on its own. As you&#8217;ll see next, it breaks down as soon as the screen is used inside other views.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why Initializer Injection Breaks Down as the App Grows</h2>



<p class="wp-block-paragraph">Now the app grows. <code>ProductsScreen</code> becomes one tab in a <code>RootTabView</code>, next to a <code>FavoritesScreen</code> that works the same way:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct RootTabView: View {
    var body: some View {
        TabView {
            Tab("Products", systemImage: "bag") {
                ProductsScreen()      // default: live service
            }
            Tab("Favorites", systemImage: "heart") {
                FavoritesScreen()     // default: live service
            }
        }
    }
}

#Preview {
    RootTabView() // both tabs use the real API and the real UserDefaults
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">RootTabView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">TabView</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Tab</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">bag</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">ProductsScreen</span><span style="color: #F6F6F4">()      </span><span style="color: #7B7F8B">// default: live service</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Tab</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Favorites</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">heart</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">FavoritesScreen</span><span style="color: #F6F6F4">()     </span><span style="color: #7B7F8B">// default: live service</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">RootTabView</span><span style="color: #F6F6F4">() </span><span style="color: #7B7F8B">// both tabs use the real API and the real UserDefaults</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The preview of <code>ProductsScreen</code> still works, because it passes the mock itself. But the preview of <code>RootTabView</code> creates both screens with their defaults, the live services. There&#8217;s no way to reach in and change that from the outside.</p>



<p class="wp-block-paragraph">With initializer injection, the only fix is to pass the services through the parent:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct RootTabView: View {
    let productService: ProductService     // not used here, only passed on
    let favoritesService: FavoritesService // not used here, only passed on

    var body: some View {
        TabView {
            Tab("Products", systemImage: "bag") {
                ProductsScreen(productService: productService)
            }
            Tab("Favorites", systemImage: "heart") {
                FavoritesScreen(favoritesService: favoritesService)
            }
        }
    }
}

#Preview {
    RootTabView(
        productService: MockProductService(products: Product.samples),
        favoritesService: MockFavoritesService()
    )
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">RootTabView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> productService: ProductService     </span><span style="color: #7B7F8B">// not used here, only passed on</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> favoritesService: FavoritesService </span><span style="color: #7B7F8B">// not used here, only passed on</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">TabView</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Tab</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">bag</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">ProductsScreen</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: productService)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Tab</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Favorites</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">heart</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">FavoritesScreen</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">favoritesService</span><span style="color: #F6F6F4">: favoritesService)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">RootTabView</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples),</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">favoritesService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockFavoritesService</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    )</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This works for one level. Here&#8217;s how it gets worse as the app grows:</p>



<ul class="wp-block-list">
<li><strong><code>RootTabView</code> takes dependencies it doesn&#8217;t use.</strong> It only has the services so it can pass them to its children.</li>



<li><strong>Every new level adds the same parameters.</strong> If <code>ProductsScreen</code> shows a <code>ProductDetailScreen</code> with its own view model, <code>ProductsScreen</code> also needs to pass the service on, even though its own view model already has one.</li>



<li><strong>Every new service adds a parameter to every view in the chain.</strong> Add a cart service, and <code>RootTabView</code>, <code>ProductsScreen</code>, and every view in between get another parameter.</li>



<li><strong>Every view above needs this to be previewable.</strong> If one view in the chain uses the default instead of passing the service on, every preview above it calls the real API again.</li>
</ul>



<p class="wp-block-paragraph">That&#8217;s why I don&#8217;t pass services through view initializers. <strong>I prefer to put them into the environment.</strong></p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Solution: Services in the SwiftUI Environment</h2>



<p class="wp-block-paragraph">The environment solves exactly the problem from the previous section. A value set on a view reaches every view below it, and none of the views in between need to know about it.</p>


<div class="gb-container gb-container-5378eee7">

<figure class="gb-block-image gb-block-image-9def0a71"><img decoding="async" width="911" height="518" class="gb-image gb-image-9def0a71" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swiftui-1.webp" alt="dependency injection in swiftui direct through view init or environment" title="dependency-injection-swiftui-1" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swiftui-1.webp 911w, https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swiftui-1-300x171.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swiftui-1-768x437.webp 768w" sizes="(max-width: 911px) 100vw, 911px" /></figure>

</div>


<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Put the service into the environment</h3>



<p class="wp-block-paragraph">With the <code>@Entry</code> macro, this is one line per service in an extension on <code>EnvironmentValues</code>:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>extension EnvironmentValues {
    @Entry var productService: ProductService = LiveProductService()
    @Entry var favoritesService: FavoritesService = LiveFavoritesService()
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">extension</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">EnvironmentValues</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Entry</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> productService: ProductService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">LiveProductService</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Entry</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> favoritesService: FavoritesService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">LiveFavoritesService</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<ul class="wp-block-list">
<li><strong>The type is the protocol.</strong> If it were <code>LiveProductService</code>, you couldn&#8217;t put a mock in its place.</li>



<li><strong>The default is the live service.</strong> If nobody sets a value, every view gets the live service, so the app works without any setup at the root.</li>



<li><strong>A live default is safe here</strong> because the services are stateless <code>nonisolated</code> <code>Sendable</code> structs. Without an injected value, SwiftUI may create the default more than once, and for a stateless service, extra copies change nothing.</li>
</ul>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Read it and create the view model</h3>



<p class="wp-block-paragraph">The screen reads the service with <code>@Environment</code>. The environment isn&#8217;t available yet when the view&#8217;s <code>init</code> runs, so the view model can&#8217;t be created there anymore. Instead, it&#8217;s an optional and gets created in <code>.task</code>:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ProductsScreen: View {
    @Environment(.productService) private var productService
    @State private var viewModel: ProductViewModel?

    var body: some View {
        Group {
            if let viewModel {
                ProductListView(state: viewModel.state)
            } else {
                ProgressView()
            }
        }
        .task {
            if viewModel == nil {
                viewModel = ProductViewModel(productService: productService)
            }
            await viewModel?.load()
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductsScreen</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Environment</span><span style="color: #F6F6F4">(.productService) </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> productService</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel: ProductViewModel</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">Group</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> viewModel {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">state</span><span style="color: #F6F6F4">: viewModel.state)</span></span>
<span class="line"><span style="color: #F6F6F4">            } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">ProgressView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">task</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span><span style="color: #F6F6F4"> {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: productService)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4">.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<ul class="wp-block-list">
<li><strong><code>.task</code> runs every time the view appears</strong>, for example when the user switches back to this tab. The <code>if viewModel == nil</code> check makes sure the view model is only created the first time.</li>



<li><strong>The view model is created exactly once.</strong> </li>
</ul>



<p class="wp-block-paragraph"><code>RootTabView</code> goes back to having no parameters at all:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct RootTabView: View {
    var body: some View {
        TabView {
            Tab("Products", systemImage: "bag") {
                ProductsScreen()
            }
            Tab("Favorites", systemImage: "heart") {
                FavoritesScreen()
            }
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">RootTabView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">TabView</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Tab</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">bag</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">ProductsScreen</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Tab</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Favorites</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">heart</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">FavoritesScreen</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Override it where you need a different service</h3>



<p class="wp-block-paragraph">Now one modifier on any view replaces the service for everything below it:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>#Preview("Root") {
    RootTabView()
        .environment(.productService, MockProductService(products: Product.samples))
        .environment(.favoritesService, MockFavoritesService())
}

#Preview("Products") {
    ProductsScreen()
        .environment(.productService, MockProductService(products: Product.samples))
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Root</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">RootTabView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">environment</span><span style="color: #F6F6F4">(.productService, </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples))</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">environment</span><span style="color: #F6F6F4">(.favoritesService, </span><span style="color: #97E1F1">MockFavoritesService</span><span style="color: #F6F6F4">())</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProductsScreen</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">environment</span><span style="color: #F6F6F4">(.productService, </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples))</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">It doesn&#8217;t matter how deep <code>ProductsScreen</code> sits in the hierarchy. The override on the parent preview reaches it. You only set it on the views that need a different service: in previews, or on one part of the app that should use another service.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Variant: keep the view model non-optional</h3>



<p class="wp-block-paragraph">If you don&#8217;t want the optional view model, there&#8217;s a middle way. <code>ProductsScreen</code> keeps its initializer from the first example, and the view that shows it reads the environment and passes the service in:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct RootTabView: View {
    @Environment(.productService) private var productService
    @Environment(.favoritesService) private var favoritesService

    var body: some View {
        TabView {
            Tab("Products", systemImage: "bag") {
                ProductsScreen(productService: productService)
            }
            Tab("Favorites", systemImage: "heart") {
                FavoritesScreen(favoritesService: favoritesService)
            }
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">RootTabView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Environment</span><span style="color: #F6F6F4">(.productService) </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> productService</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Environment</span><span style="color: #F6F6F4">(.favoritesService) </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> favoritesService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">TabView</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Tab</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">bag</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">ProductsScreen</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: productService)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Tab</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Favorites</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">heart</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">FavoritesScreen</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">favoritesService</span><span style="color: #F6F6F4">: favoritesService)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<ul class="wp-block-list">
<li><code>ProductsScreen</code> stays as simple as in the first example, with a non-optional view model.</li>



<li>Only the parent touches the environment, and an override on its preview still reaches both tabs.</li>



<li>The caveats from the first example come back: the initial value is only used the first time, and the view model&#8217;s <code>init</code> must stay cheap.</li>
</ul>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why This Matters for UI Tests</h2>



<p class="wp-block-paragraph">The same property pays off again in UI tests. A UI test runs the app in a separate process, so it can&#8217;t pass a mock into a view. All it can do is launch the app with arguments.</p>



<p class="wp-block-paragraph">With initializer injection, the app would have to pass the mock down every chain of views to every screen. With the environment, the app reads the argument and sets the mock once, wherever it should apply. Here it&#8217;s set at the root, for the whole app:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@main
struct ShopApp: App {
    private let productService: ProductService

    init() {
        if ProcessInfo.processInfo.arguments.contains("-uiTesting") {
            productService = MockProductService(products: Product.samples)
        } else {
            productService = LiveProductService()
        }
    }

    var body: some Scene {
        WindowGroup {
            RootTabView()
                .environment(.productService, productService)
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@main</span></span>
<span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ShopApp</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">App </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> productService: ProductService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> ProcessInfo.processInfo.arguments.</span><span style="color: #97E1F1">contains</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">-uiTesting</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">            productService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples)</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            productService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">LiveProductService</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> Scene {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">WindowGroup</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">RootTabView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                .</span><span style="color: #97E1F1">environment</span><span style="color: #F6F6F4">(.productService, productService)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Every screen in the app picks up the mock, and none of them knows it&#8217;s running in a UI test. How the test passes the launch argument, and how to set up different scenarios, is the topic of UI Testing SwiftUI Apps With Mock Services.</p>



<p class="wp-block-paragraph">If only one feature should use the mock, set the same <code>.environment</code> on that feature&#8217;s view instead, like the products tab. Everything in that branch gets the mock, and the rest of the app keeps the live service.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Keeping Previews Safe</h2>



<p class="wp-block-paragraph">The live default has one downside. If a preview forgets the override, nothing tells you: the preview uses the live service and calls the real API. With one service that&#8217;s easy to remember. With several, every preview has to override every one of them.</p>



<p class="wp-block-paragraph">A small view extension collects all mocks in one place:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>extension View {
    func injectMockServices() -> some View {
        self
            .environment(.productService, MockProductService(products: Product.samples))
            .environment(.favoritesService, MockFavoritesService())
    }
}

#Preview {
    RootTabView()
        .injectMockServices()
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">extension</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">View</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">injectMockServices</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">environment</span><span style="color: #F6F6F4">(.productService, </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples))</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">environment</span><span style="color: #F6F6F4">(.favoritesService, </span><span style="color: #97E1F1">MockFavoritesService</span><span style="color: #F6F6F4">())</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">RootTabView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">injectMockServices</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">When you add a new service, you add its mock here once, and every preview that uses <code>injectMockServices()</code> gets it.</p>



<p class="wp-block-paragraph">To use a live service for one preview and mocks for everything else, set the specific value before the modifier:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>#Preview("Real Product API") {
    RootTabView()
        .environment(.productService, LiveProductService()) // must come before injectMockServices()
        .injectMockServices()
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Real Product API</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">RootTabView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">environment</span><span style="color: #F6F6F4">(.productService, </span><span style="color: #97E1F1">LiveProductService</span><span style="color: #F6F6F4">()) </span><span style="color: #7B7F8B">// must come before injectMockServices()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">injectMockServices</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The order matters because environment values set closer to the view win. The <code>.environment</code> call directly on <code>RootTabView</code> is applied innermost, so it overrides the value that <code>injectMockServices()</code> sets further out.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Alternative: DI Containers Like Factory</h2>



<p class="wp-block-paragraph">The environment works, but it has a cost. Every screen now has an <code>@Environment</code> property, an optional <code>@State</code> view model, a <code>.task</code> that creates it, and a <code>nil</code> case to handle. With a handful of screens, that&#8217;s fine. With many, it&#8217;s the same few lines over and over. That&#8217;s usually why people reach for a DI container. One of the most popular one in Swift is <a href="https://github.com/hmlongco/Factory" data-type="link" data-id="https://github.com/hmlongco/Factory" target="_blank" rel="noopener">Factory</a>.</p>



<p class="wp-block-paragraph">Another option is Point-Free&#8217;s <a href="https://github.com/pointfreeco/swift-dependencies" data-type="link" data-id="https://github.com/pointfreeco/swift-dependencies" target="_blank" rel="noopener">swift-dependencies</a>, which takes a similar approach with a <code>@Dependency</code> property wrapper, and is built around task-local values from the start.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">What Factory automates</h4>



<p class="wp-block-paragraph">Each dependency is registered once, in an extension on Factory&#8217;s <code>Container</code>. The view model then resolves it with the <code>@Injected</code> property wrapper:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>import Factory

extension Container {
    var productService: Factory&lt;ProductService> {
        self { LiveProductService() }
    }
}

@MainActor
@Observable
final class ProductViewModel {
    @ObservationIgnored
    @Injected(.productService) private var productService

    private(set) var state: LoadingState&lt;&#91;Product&#93;> = .idle

    func load() async { /* ... */ }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">import</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Factory</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">extension</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Container</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> productService: Factory&lt;ProductService&gt; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">LiveProductService</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@ObservationIgnored</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Injected</span><span style="color: #F6F6F4">(.productService) </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> productService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> state: LoadingState&lt;&#91;Product&#93;&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .idle</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">load</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">The main win: a simpler view</h4>



<p class="wp-block-paragraph">Compare the screen with the environment version from above. With Factory, the view model gets its service on its own, so the view just creates it:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ProductsScreen: View {
    @State private var viewModel = ProductViewModel()

    var body: some View {
        ProductListView(state: viewModel.state)
            .task { await viewModel.load() }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductsScreen</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">()</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">state</span><span style="color: #F6F6F4">: viewModel.state)</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">task</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">No <code>@Environment</code> property, no optional view model, no <code>nil</code> case, and no creating the view model in <code>.task</code>. And that&#8217;s on every screen.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">Overrides for previews</h4>



<p class="wp-block-paragraph">Instead of <code>.environment</code> overrides, you register a different version. Here are the same examples as above, with Factory.</p>



<p class="wp-block-paragraph">The parent preview:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>#Preview {
    let _ = Container.shared.productService.register {
        MockProductService(products: Product.samples)
    }
    RootTabView()
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> Container.shared.productService.</span><span style="color: #97E1F1">register</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">RootTabView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">Resolving the whole dependency graph</h4>



<p class="wp-block-paragraph">The bigger benefit shows up when dependencies depend on each other. The view model needs a product service, the service needs an <code>APIClient</code>, and the <code>APIClient</code> needs the token store that holds the current auth token. Without a container, someone has to create all of these in the right order and pass each one into the next.</p>



<p class="wp-block-paragraph">With Factory, each dependency is registered once, together with what it needs:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>extension Container {
    var tokenStore: Factory&lt;TokenStore> {
        self { LiveTokenStore() }.singleton
    }

    var apiClient: Factory&lt;APIClient> {
        self { APIClient(tokenStore: self.tokenStore()) }
    }

    var productService: Factory&lt;ProductService> {
        self { LiveProductService(apiClient: self.apiClient()) }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">extension</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Container</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> tokenStore: Factory&lt;TokenStore&gt; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">LiveTokenStore</span><span style="color: #F6F6F4">() }.singleton</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> apiClient: Factory&lt;APIClient&gt; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">APIClient</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">tokenStore</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.</span><span style="color: #97E1F1">tokenStore</span><span style="color: #F6F6F4">()) }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> productService: Factory&lt;ProductService&gt; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">LiveProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">apiClient</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.</span><span style="color: #97E1F1">apiClient</span><span style="color: #F6F6F4">()) }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">When the view model asks for <code>productService</code>, Factory resolves the whole chain. <code>.singleton</code> makes sure the whole app shares one token store. How to build the same graph without a framework is the topic of Building the Dependency Graph in SwiftUI.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">Where it gets harder</h4>



<ul class="wp-block-list">
<li><strong>Parallel tests.</strong> Registrations live in one shared container, so tests that run at the same time can overwrite each other&#8217;s mocks. To keep them separate, each test gets its own container through a <code>@TaskLocal</code>. The drawback is making that work reliably: task-local values only reach code that runs inside the test&#8217;s task. Point-Free put a lot of work into this in their swift-dependencies library, and I&#8217;m still not sure I&#8217;d catch every case. I&#8217;d rather not spend my time hunting test failures that only show up sometimes.</li>



<li><strong>Hidden dependencies.</strong> Dependencies no longer show up in the initializer. Nothing reminds you to register a mock, so it&#8217;s easy to forget one, for example in a preview, and silently call the live service.</li>



<li><strong>Multi-window apps.</strong> Factory&#8217;s scopes are app-wide, not per window. A repository, an object that holds state below the UI like an image cache, is shared by every window if it&#8217;s a singleton. On iPad and Mac, each window often needs its own repository, with its own cache or selection, while the token store stays one per app. With the environment, this comes for free: each window has its own view tree, so a repository created inside the <code>WindowGroup</code> content exists once per window. With Factory, you need a custom scope or have to pass a window identifier around. More on per-window lifetimes in Building the Dependency Graph in SwiftUI.</li>
</ul>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">My take</h4>



<p class="wp-block-paragraph">I show DI frameworks as an option, not a recommendation. They look easy at the start: register a dependency, add <code>@Injected</code>, done. The problems come later. If the scopes and overrides aren&#8217;t set up right, you get shared state you didn&#8217;t expect, like the multi-window case above, and the dependencies no longer show up in the initializer. And once the whole app resolves its dependencies through the framework, moving away from it means touching every view model.</p>



<p class="wp-block-paragraph">The initializer and environment approach needs more code, but it&#8217;s plain Swift and SwiftUI that you can read, understand, and change.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Outlook: MVVM-C</h2>



<p class="wp-block-paragraph">There&#8217;s one more way to look at this. With coordinators, views no longer create their view models at all. The coordinator builds each view model with plain initializer injection and passes the dependencies from parent to child coordinator. That replaces most of the environment setup from this post, and gives per-window instances with one coordinator per window.</p>



<p class="wp-block-paragraph">That&#8217;s a bigger topic, so it gets two posts of its own: MVVM-C in SwiftUI: Why, What, and How, and Dependency Injection With MVVM-C.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Summary</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-left" data-align="left">Approach</th><th class="has-text-align-left" data-align="left">Verdict</th></tr></thead><tbody><tr><td class="has-text-align-left" data-align="left">Service through the view&#8217;s <code>init</code></td><td class="has-text-align-left" data-align="left">Works for a single screen, breaks down once the screen is used inside other views</td></tr><tr><td class="has-text-align-left" data-align="left">Service in the environment</td><td class="has-text-align-left" data-align="left">What I use: reaches screens at any depth, and one override switches previews and UI tests to mocks</td></tr><tr><td class="has-text-align-left" data-align="left">DI container like Factory</td><td class="has-text-align-left" data-align="left">An option, with trade-offs: parallel tests, hidden dependencies, and multi-window apps</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">There&#8217;s also a limit. Everything in this post works because the services are stateless, so a live default and extra copies are harmless. A token store or a repository with a cache holds state, and there a live default would quietly create a second instance. Where and how to create those is the topic of Building the Dependency Graph in SwiftUI.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>


<div class="gb-container gb-container-c5dedc2e">
<div class="gb-container gb-container-f7a433c7">
<div class="gb-container gb-container-7a8dc7a3">

<figure class="gb-block-image gb-block-image-f04fbbad"><a href="https://www.swiftyplace.com/free-swiftui-layout-cookbook" target="_blank" rel="noopener noreferrer"><img loading="lazy" decoding="async" width="1446" height="1552" class="gb-image gb-image-f04fbbad" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp" alt="" title="lead magnet mvvm" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp 1446w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-280x300.webp 280w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-954x1024.webp 954w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-768x824.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-1431x1536.webp 1431w" sizes="auto, (max-width: 1446px) 100vw, 1446px" /></a></figure>

</div>

<div class="gb-container gb-container-98352fe6">

<h4 class="gb-headline gb-headline-4aacd94c gb-headline-text">Get the MVVM SwiftUI Cheat Sheet</h4>



<p class="gb-headline gb-headline-5e6c4f85 gb-headline-text">The cheat sheet puts the whole system on a few pages: what goes in a view model, which view owns it, how to pass it down the tree, common mistakes, and code skeletons you can copy.</p>


<div class="gb-container gb-container-34a8ad02">

<a class="gb-button gb-button-63e90e76 gb-button-blue" href="https://school.swiftyplace.com/f/swiftui-mvvm-cheat-sheet" target="_blank" rel="noopener noreferrer"><span class="gb-button-text">Get the free cheat sheet</span><span class="gb-icon"><svg aria-hidden="true" role="img" height="1em" width="1em" viewBox="0 0 256 512" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"></path></svg></span></a>

</div>
</div>
</div>
</div>


<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">Further Reading</h4>



<ul class="wp-block-list">
<li><a href="https://www.swiftyplace.com/blog/swiftui-mvvm" data-type="link" data-id="https://www.swiftyplace.com/blog/swiftui-mvvm">What Is a View Model in SwiftUI, Really?</a></li>



<li><a href="https://www.swiftyplace.com/blog/mvvm-view-model-use-case">When to Use a View Model in SwiftUI (and When It’s Overkill)</a></li>



<li><a href="https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel">What Should Go in a View Model in SwiftUI?</a></li>



<li><a href="https://www.swiftyplace.com/blog/dependency-injection-swift" data-type="link" data-id="https://www.swiftyplace.com/blog/dependency-injection-swift">Dependency Injection in Swift: Services, Protocols, and Testable View Models</a></li>
</ul>



<p class="wp-block-paragraph"></p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/dependency-injection-swiftui">Dependency Injection in SwiftUI: Initializer, Environment, or Container</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/dependency-injection-swiftui/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Dependency Injection in Swift: Services, Protocols, and Testable View Models</title>
		<link>https://www.swiftyplace.com/blog/dependency-injection-swift?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=dependency-injection-swift</link>
					<comments>https://www.swiftyplace.com/blog/dependency-injection-swift#respond</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Sun, 01 Nov 2026 12:10:16 +0000</pubDate>
				<category><![CDATA[SwiftUI]]></category>
		<category><![CDATA[SwiftUI Architecture]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005784</guid>

					<description><![CDATA[<p>Your view model needs UserDefaults, the network, or the file system, and that’s exactly what breaks your previews and tests. Learn dependency injection in Swift step by step: move that work into services, let view models depend on a protocol, and swap in mocks for SwiftUI previews and unit tests that are fast and reliable.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/dependency-injection-swift">Dependency Injection in Swift: Services, Protocols, and Testable View Models</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><a href="https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel">In What Should Go in a View Model?</a>, we defined the view model&#8217;s job: it owns UI state and is responsible for mutating that state properly and consistently. But to do that job, it almost always needs something from outside your own code. Products come from the network. Themes are saved to disk. Settings live in <code>UserDefaults</code>.</p>



<p class="wp-block-paragraph">The easiest way to get there is to write that code right into the view model. A <code>URLSession</code> call here, a <code>JSONDecoder</code> there. It works, until you open a preview and it spins forever, or crashes because there&#8217;s no server. Or until you try to write a test, and it&#8217;s slow, fails randomly, or can&#8217;t be written at all.</p>



<p class="wp-block-paragraph">In this post, I&#8217;ll show you how to fix both problems with dependency injection in Swift: move that work into services, let your view models depend on a protocol instead of the real thing, and inject what you need. By the end, every view model can run in a preview and a unit test without touching <code>UserDefaults</code> or the network.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why keeping UserDefaults Code Inside the View Model becomes a Problem</h2>



<p class="wp-block-paragraph">Let&#8217;s start with a simple example. Users can mark products as favorites, and the favorites are saved in <code>UserDefaults</code>. Here&#8217;s the version most of us write first, with <code>UserDefaults</code> right inside the view model:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class FavoritesViewModel {
    private(set) var favoriteIDs: Set&lt;Product.ID> = []
    private let key = "favoriteProductIDs"

    init() {
        // <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Reading UserDefaults inside the view model
        favoriteIDs = Set(UserDefaults.standard.stringArray(forKey: key) ?? [])
    }

    func toggleFavorite(_ product: Product) {
        if favoriteIDs.contains(product.id) {
            favoriteIDs.remove(product.id)
        } else {
            favoriteIDs.insert(product.id)
        }
        // <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Writing UserDefaults inside the view model
        UserDefaults.standard.set(Array(favoriteIDs), forKey: key)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> favoriteIDs: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> key </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">favoriteProductIDs</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #7B7F8B">// &#x26a0; Reading UserDefaults inside the view model</span></span>
<span class="line"><span style="color: #F6F6F4">        favoriteIDs </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">(UserDefaults.standard.</span><span style="color: #97E1F1">stringArray</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">forKey</span><span style="color: #F6F6F4">: key) </span><span style="color: #F286C4">??</span><span style="color: #F6F6F4"> [])</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleFavorite</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">product</span><span style="color: #F6F6F4">: Product) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> favoriteIDs.</span><span style="color: #97E1F1">contains</span><span style="color: #F6F6F4">(product.id) {</span></span>
<span class="line"><span style="color: #F6F6F4">            favoriteIDs.</span><span style="color: #97E1F1">remove</span><span style="color: #F6F6F4">(product.id)</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            favoriteIDs.</span><span style="color: #97E1F1">insert</span><span style="color: #F6F6F4">(product.id)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #7B7F8B">// &#x26a0; Writing UserDefaults inside the view model</span></span>
<span class="line"><span style="color: #F6F6F4">        UserDefaults.standard.</span><span style="color: #BF9EEE">set</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1; font-style: italic">Array</span><span style="color: #F6F6F4">(favoriteIDs), </span><span style="color: #97E1F1">forKey</span><span style="color: #F6F6F4">: key)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This works in the app. The problems show up everywhere else.</p>



<p class="wp-block-paragraph"><strong>Previews keep what you tapped.</strong> Tap a heart in the preview, and it&#8217;s written to the real <code>UserDefaults</code>. Open the preview again tomorrow, and the favorite is still there. You can&#8217;t reliably show the empty state, and to show a screen with three favorites, you have to tap them again every time, or write test data into <code>UserDefaults</code> by hand.</p>



<p class="wp-block-paragraph"><strong>Tests depend on each other.</strong> Say one test checks that toggling adds a favorite, and another checks that a new view model starts with no favorites:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Test func toggleAddsFavorite() {
    let viewModel = FavoritesViewModel()
    viewModel.toggleFavorite(Product.samples&#91;0&#93;)
    #expect(viewModel.favoriteIDs.count == 1)
}

@Test func startsWithNoFavorites() {
    let viewModel = FavoritesViewModel()
    #expect(viewModel.favoriteIDs.isEmpty) // <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/274c.png" alt="❌" class="wp-smiley" style="height: 1em; max-height: 1em;" /> fails if the other test ran first
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Test</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleAddsFavorite</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    viewModel.</span><span style="color: #97E1F1">toggleFavorite</span><span style="color: #F6F6F4">(Product.samples&#91;</span><span style="color: #BF9EEE">0</span><span style="color: #F6F6F4">&#93;)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">#expect</span><span style="color: #F6F6F4">(viewModel.favoriteIDs.</span><span style="color: #BF9EEE">count</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">1</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">@Test</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">startsWithNoFavorites</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">#expect</span><span style="color: #F6F6F4">(viewModel.favoriteIDs.</span><span style="color: #BF9EEE">isEmpty</span><span style="color: #F6F6F4">) </span><span style="color: #7B7F8B">// &#x274c; fails if the other test ran first</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Both tests read and write the same key in the same <code>UserDefaults</code>. Whether the second one passes depends on which test runs first, and on what earlier runs left behind. Run them in parallel, and they fail at random. That&#8217;s the worst kind of test: one you stop trusting.</p>



<p class="wp-block-paragraph"><strong>The view model has two jobs.</strong> It manages the favorites as UI state, and it knows how they&#8217;re stored: the key, the format, the storage API. Switch to iCloud or a file later, and you rewrite the view model. Need favorites in a second view model, and you copy the storage code.</p>



<p class="wp-block-paragraph"><code>UserDefaults</code> is just one example. The problem has a name: a <strong>shared external dependency</strong>, a resource outside your own code that&#8217;s shared with the system, the rest of your app, and other tests. Think of:</p>



<ul class="wp-block-list">
<li>the network</li>



<li>the file system</li>



<li><code>UserDefaults</code> and the Keychain</li>



<li>HealthKit, location, contacts</li>



<li>even the current date and time</li>
</ul>



<p class="wp-block-paragraph">With the network, it gets worse: requests are slow, and they fail for reasons that have nothing to do with your code. That leads to a simple rule: <strong>shared external dependencies go behind a boundary you control.</strong> Everything else, like your models and the view model&#8217;s own logic, can run for real.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Step 1: Move the Storage Code Into a Service</h2>



<p class="wp-block-paragraph">So the storage code moves into a <strong>service</strong>. A service is a helper worker: a stateless type that handles one kind of work with a shared external dependency.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct FavoritesService {
    private let key = "favoriteProductIDs"

    func loadFavoriteIDs() -> Set&lt;Product.ID> {
        Set(UserDefaults.standard.stringArray(forKey: key) ?? [])
    }

    func saveFavoriteIDs(_ ids: Set&lt;Product.ID>) {
        UserDefaults.standard.set(Array(ids), forKey: key)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">FavoritesService</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> key </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">favoriteProductIDs</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadFavoriteIDs</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">(UserDefaults.standard.</span><span style="color: #97E1F1">stringArray</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">forKey</span><span style="color: #F6F6F4">: key) </span><span style="color: #F286C4">??</span><span style="color: #F6F6F4"> [])</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">saveFavoriteIDs</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">ids</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt;) {</span></span>
<span class="line"><span style="color: #F6F6F4">        UserDefaults.standard.</span><span style="color: #BF9EEE">set</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1; font-style: italic">Array</span><span style="color: #F6F6F4">(ids), </span><span style="color: #97E1F1">forKey</span><span style="color: #F6F6F4">: key)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<ul class="wp-block-list">
<li><strong>Stateless:</strong> it has no properties that change. It takes input, does the work, and returns a result. The favorites themselves stay in the view model.</li>



<li><strong>Reusable:</strong> any view model that needs favorites can use it, like a product list and a product detail screen.</li>



<li><strong>Ready for the background:</strong> it&#8217;s <code>nonisolated</code>, so it isn&#8217;t tied to the main actor. Reading <code>UserDefaults</code> is fast, but for slow work like network requests, the service can run its functions in the background, as you&#8217;ll see in the second example.</li>
</ul>



<p class="wp-block-paragraph">The view model keeps its job, managing the state, and hands the storage work to the service:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Observable
final class FavoritesViewModel {
    private(set) var favoriteIDs: Set&lt;Product.ID> = []
    private let favoritesService = FavoritesService()

    init() {
        favoriteIDs = favoritesService.loadFavoriteIDs()
    }

    func toggleFavorite(_ product: Product) {
        if favoriteIDs.contains(product.id) {
            favoriteIDs.remove(product.id)
        } else {
            favoriteIDs.insert(product.id)
        }
        favoritesService.saveFavoriteIDs(favoriteIDs)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> favoriteIDs: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> favoritesService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesService</span><span style="color: #F6F6F4">()</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        favoriteIDs </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> favoritesService.</span><span style="color: #97E1F1">loadFavoriteIDs</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleFavorite</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">product</span><span style="color: #F6F6F4">: Product) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> favoriteIDs.</span><span style="color: #97E1F1">contains</span><span style="color: #F6F6F4">(product.id) {</span></span>
<span class="line"><span style="color: #F6F6F4">            favoriteIDs.</span><span style="color: #97E1F1">remove</span><span style="color: #F6F6F4">(product.id)</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            favoriteIDs.</span><span style="color: #97E1F1">insert</span><span style="color: #F6F6F4">(product.id)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        favoritesService.</span><span style="color: #97E1F1">saveFavoriteIDs</span><span style="color: #F6F6F4">(favoriteIDs)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">One naming rule I stick to: a service never holds UI state. If something called a &#8220;service&#8221; has <code>@Observable</code> properties that views read, it&#8217;s a view model with the wrong name.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Step 2: Dependency Injection With a Protocol (the Dependency Inversion Principle)</h2>



<p class="wp-block-paragraph">Moving the storage code into a service cleaned up the view model. But it didn&#8217;t solve the preview and test problem yet. The view model creates the service itself, so it&#8217;s still locked to the real <code>UserDefaults</code>:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Observable
final class FavoritesViewModel {
    private(set) var favoriteIDs: Set&lt;Product.ID> = []
    private let favoritesService = FavoritesService() // <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> always the real UserDefaults

    // ...
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> favoriteIDs: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> favoritesService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesService</span><span style="color: #F6F6F4">() </span><span style="color: #7B7F8B">// &#x26a0; always the real UserDefaults</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// ...</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">No preview or test can change that. The fix is to flip the dependency around. Instead of depending on the concrete service, the view model depends on a description of what it needs: a <strong>protocol</strong>. That&#8217;s the <strong>Dependency Inversion Principle (DIP)</strong>: high-level code, like your view model, shouldn&#8217;t depend on low-level details, like the code that reads and writes UserDefaults. Both depend on an abstraction in between: the protocol.</p>


<div class="gb-container gb-container-1f50d190">

<figure class="gb-block-image gb-block-image-80d3092d"><img loading="lazy" decoding="async" width="970" height="516" class="gb-image gb-image-80d3092d" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-1.webp" alt="dependency injection swift with a viewmodel to test logic" title="dependency-injection-swift-1" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-1.webp 970w, https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-1-300x160.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-1-768x409.webp 768w" sizes="auto, (max-width: 970px) 100vw, 970px" /></figure>

</div>


<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The protocol describes what the view model needs:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated protocol FavoritesService: Sendable {
    func loadFavoriteIDs() -> Set&lt;Product.ID>
    func saveFavoriteIDs(_ ids: Set&lt;Product.ID>)
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">protocol</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">FavoritesService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadFavoriteIDs</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt;</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">saveFavoriteIDs</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">ids</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt;)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The service we wrote above becomes the live implementation:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct LiveFavoritesService: FavoritesService {
    private let key = "favoriteProductIDs"

    func loadFavoriteIDs() -> Set&lt;Product.ID> {
        Set(UserDefaults.standard.stringArray(forKey: key) ?? [])
    }

    func saveFavoriteIDs(_ ids: Set&lt;Product.ID>) {
        UserDefaults.standard.set(Array(ids), forKey: key)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">LiveFavoritesService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">FavoritesService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> key </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">favoriteProductIDs</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadFavoriteIDs</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">(UserDefaults.standard.</span><span style="color: #97E1F1">stringArray</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">forKey</span><span style="color: #F6F6F4">: key) </span><span style="color: #F286C4">??</span><span style="color: #F6F6F4"> [])</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">saveFavoriteIDs</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">ids</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt;) {</span></span>
<span class="line"><span style="color: #F6F6F4">        UserDefaults.standard.</span><span style="color: #BF9EEE">set</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1; font-style: italic">Array</span><span style="color: #F6F6F4">(ids), </span><span style="color: #97E1F1">forKey</span><span style="color: #F6F6F4">: key)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The protocol is <code>nonisolated</code> and <code>Sendable</code>, so the view model on the main actor can hold it, and implementations aren&#8217;t tied to the main actor.</p>



<p class="wp-block-paragraph">Now the view model receives whichever implementation you want through its initializer, with the live service as the default:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class FavoritesViewModel {
    private(set) var favoriteIDs: Set&lt;Product.ID> = []
    private let favoritesService: FavoritesService

    init(favoritesService: FavoritesService = LiveFavoritesService()) {
        self.favoritesService = favoritesService
        favoriteIDs = favoritesService.loadFavoriteIDs()
    }

    func toggleFavorite(_ product: Product) {
        if favoriteIDs.contains(product.id) {
            favoriteIDs.remove(product.id)
        } else {
            favoriteIDs.insert(product.id)
        }
        favoritesService.saveFavoriteIDs(favoriteIDs)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> favoriteIDs: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> favoritesService: FavoritesService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">favoritesService</span><span style="color: #F6F6F4">: FavoritesService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">LiveFavoritesService</span><span style="color: #F6F6F4">()) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.favoritesService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> favoritesService</span></span>
<span class="line"><span style="color: #F6F6F4">        favoriteIDs </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> favoritesService.</span><span style="color: #97E1F1">loadFavoriteIDs</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleFavorite</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">product</span><span style="color: #F6F6F4">: Product) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> favoriteIDs.</span><span style="color: #97E1F1">contains</span><span style="color: #F6F6F4">(product.id) {</span></span>
<span class="line"><span style="color: #F6F6F4">            favoriteIDs.</span><span style="color: #97E1F1">remove</span><span style="color: #F6F6F4">(product.id)</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            favoriteIDs.</span><span style="color: #97E1F1">insert</span><span style="color: #F6F6F4">(product.id)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        favoritesService.</span><span style="color: #97E1F1">saveFavoriteIDs</span><span style="color: #F6F6F4">(favoriteIDs)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">In the app, nothing changes: <code>FavoritesViewModel()</code> uses the live service. Previews and tests pass in a mock. That&#8217;s all dependency injection is: the view model gets its dependencies from outside instead of creating them itself.</p>



<p class="wp-block-paragraph">Initializer injection is the simplest way, and it&#8217;s what this post uses. It&#8217;s not the only one. In a real SwiftUI app, the bigger question is how dependencies get from the app&#8217;s entry point to every view model that needs them, especially when several view models share one instance. Common approaches are the SwiftUI environment and a dependency container. That&#8217;s a topic of its own, which I cover in SwiftUI Dependency Injection: Initializer, Environment, or Container?.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What You Gain: Previews for Every State</h2>



<p class="wp-block-paragraph">Here&#8217;s where the protocol pays off first. A mock conforms to the same protocol, but instead of touching <code>UserDefaults</code>, it starts with whatever favorites you give it and keeps everything in memory:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct MockFavoritesService: FavoritesService {
    var favoriteIDs: Set&lt;Product.ID> = []

    func loadFavoriteIDs() -> Set&lt;Product.ID> { favoriteIDs }
    func saveFavoriteIDs(_ ids: Set&lt;Product.ID>) {}
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">MockFavoritesService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">FavoritesService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> favoriteIDs: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadFavoriteIDs</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt; { favoriteIDs }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">saveFavoriteIDs</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">ids</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">&lt;Product.ID&gt;) {}</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Now each preview injects a differently configured mock. <code>@Previewable @State</code> makes the preview the owner of the view model, just like the parent view in your app, so it&#8217;s created once instead of on every preview update:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>#Preview("No Favorites") {
    @Previewable @State var viewModel = FavoritesViewModel(
        favoritesService: MockFavoritesService()
    )
    FavoritesView(viewModel: viewModel)
}

#Preview("With Favorites") {
    @Previewable @State var viewModel = FavoritesViewModel(
        favoritesService: MockFavoritesService(
            favoriteIDs: Set(Product.samples.prefix(3).map(.id))
        )
    )
    FavoritesView(viewModel: viewModel)
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">No Favorites</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Previewable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">favoritesService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockFavoritesService</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    )</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">FavoritesView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: viewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">With Favorites</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Previewable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">favoritesService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockFavoritesService</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">favoriteIDs</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Set</span><span style="color: #F6F6F4">(Product.samples.</span><span style="color: #97E1F1">prefix</span><span style="color: #F6F6F4">(</span><span style="color: #BF9EEE">3</span><span style="color: #F6F6F4">).</span><span style="color: #97E1F1">map</span><span style="color: #F6F6F4">(.id))</span></span>
<span class="line"><span style="color: #F6F6F4">        )</span></span>
<span class="line"><span style="color: #F6F6F4">    )</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">FavoritesView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: viewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Every state of the screen is one click away in Xcode, and tapping favorites in the preview never changes what&#8217;s saved on your Mac or simulator. Keep sample data like <code>Product.samples</code> next to the model, so previews and tests share it.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What You Gain: Reliable Unit Tests for View Models</h2>



<p class="wp-block-paragraph">The same mock makes unit tests straightforward. Inject it, call a method, and check the state:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>import Testing

@MainActor
struct FavoritesViewModelTests {
    @Test func toggleAddsFavorite() {
        let product = Product.samples&#91;0&#93;
        let viewModel = FavoritesViewModel(favoritesService: MockFavoritesService())

        viewModel.toggleFavorite(product)

        #expect(viewModel.favoriteIDs == &#91;product.id&#93;)
    }

    @Test func toggleRemovesExistingFavorite() {
        let product = Product.samples&#91;0&#93;
        let viewModel = FavoritesViewModel(
            favoritesService: MockFavoritesService(favoriteIDs: &#91;product.id&#93;)
        )

        viewModel.toggleFavorite(product)

        #expect(viewModel.favoriteIDs.isEmpty)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">import</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Testing</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">FavoritesViewModelTests</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Test</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleAddsFavorite</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> product </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> Product.samples&#91;</span><span style="color: #BF9EEE">0</span><span style="color: #F6F6F4">&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">favoritesService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockFavoritesService</span><span style="color: #F6F6F4">())</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        viewModel.</span><span style="color: #97E1F1">toggleFavorite</span><span style="color: #F6F6F4">(product)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">#expect</span><span style="color: #F6F6F4">(viewModel.favoriteIDs </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> &#91;product.id&#93;)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Test</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">toggleRemovesExistingFavorite</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> product </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> Product.samples&#91;</span><span style="color: #BF9EEE">0</span><span style="color: #F6F6F4">&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FavoritesViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">favoritesService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockFavoritesService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">favoriteIDs</span><span style="color: #F6F6F4">: &#91;product.id&#93;)</span></span>
<span class="line"><span style="color: #F6F6F4">        )</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        viewModel.</span><span style="color: #97E1F1">toggleFavorite</span><span style="color: #F6F6F4">(product)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">#expect</span><span style="color: #F6F6F4">(viewModel.favoriteIDs.</span><span style="color: #BF9EEE">isEmpty</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:18px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This solves the leaky test problem from the beginning. With the live service, the first test would write a favorite to the real <code>UserDefaults</code>, and the second test, or the next run, would find it there. With the mock, every test starts with exactly the favorites it sets up, and nothing is left behind. That also means the tests are fast and can run in parallel.</p>



<p class="wp-block-paragraph">Test what the view model is responsible for: how its state changes when you call its methods. What you don&#8217;t test here is <code>UserDefaults</code> itself, and you don&#8217;t test the UI; the view model only exposes state.</p>



<div style="height:18px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">A Second Example: Loading Products From the Network</h2>



<p class="wp-block-paragraph">The same pattern works for every shared external dependency. The network adds two things the favorites example didn&#8217;t need: the work is slow, so it should run in the background, and the screen now has loading and error states.</p>



<p class="wp-block-paragraph">The protocol describes what the view model needs, and the live service does the request and decoding in the background with <code>@concurrent</code>:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated protocol ProductService: Sendable {
    func fetchProducts() async throws -> &#91;Product&#93;
}

nonisolated struct LiveProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> &#91;Product&#93; {
        let url = URL(string: "https://api.example.com/products")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(&#91;Product&#93;.self, from: data)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">protocol</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">LiveProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ProductService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@concurrent</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> url </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">URL</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">string</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">https://api.example.com/products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span><span style="color: #F286C4">!</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> (data, </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> URLSession.shared.</span><span style="color: #97E1F1">data</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: url)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">JSONDecoder</span><span style="color: #F6F6F4">().</span><span style="color: #97E1F1">decode</span><span style="color: #F6F6F4">(&#91;Product&#93;.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: data)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:18px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The view model receives the service through its initializer and manages the loading state:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class ProductViewModel {
    private(set) var state: LoadingState&lt;&#91;Product&#93;> = .idle
    private let productService: ProductService

    init(productService: ProductService = LiveProductService()) {
        self.productService = productService
    }

    func load() async {
        state = .loading
        do {
            state = .loaded(try await productService.fetchProducts())
        } catch {
            state = .failed("Couldn't load products.")
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> state: LoadingState&lt;&#91;Product&#93;&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .idle</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> productService: ProductService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">productService</span><span style="color: #F6F6F4">: ProductService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">LiveProductService</span><span style="color: #F6F6F4">()) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.productService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> productService</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">load</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        state </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .loading</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">do</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            state </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">loaded</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> productService.</span><span style="color: #97E1F1">fetchProducts</span><span style="color: #F6F6F4">())</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">catch</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            state </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">failed</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Couldn&#39;t load products.</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:18px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The mock can now do three things: return products, throw an error, or wait to show the loading state:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct MockProductService: ProductService {
    var products: &#91;Product&#93; = []
    var error: Error? = nil
    var delay: Duration = .zero

    func fetchProducts() async throws -> &#91;Product&#93; {
        try await Task.sleep(for: delay)
        if let error { throw error }
        return products
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">MockProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ProductService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> products: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> error: </span><span style="color: #97E1F1; font-style: italic">Error</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> delay: Duration </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .zero</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> Task.</span><span style="color: #97E1F1">sleep</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: delay)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> error { </span><span style="color: #F286C4">throw</span><span style="color: #F6F6F4"> error }</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> products</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:18px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">That gives you a preview for every state of the screen, including the empty and error states that are usually hard to reproduce:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>#Preview("Loaded") {
    @Previewable @State var viewModel = ProductViewModel(
        productService: MockProductService(products: Product.samples)
    )
    ProductListView(viewModel: viewModel)
}

#Preview("Empty") {
    @Previewable @State var viewModel = ProductViewModel(
        productService: MockProductService(products: [])
    )
    ProductListView(viewModel: viewModel)
}

#Preview("Error") {
    @Previewable @State var viewModel = ProductViewModel(
        productService: MockProductService(error: URLError(.notConnectedToInternet))
    )
    ProductListView(viewModel: viewModel)
}

#Preview("Loading") {
    @Previewable @State var viewModel = ProductViewModel(
        productService: MockProductService(delay: .seconds(3600))
    )
    ProductListView(viewModel: viewModel)
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Loaded</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Previewable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples)</span></span>
<span class="line"><span style="color: #F6F6F4">    )</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: viewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Empty</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Previewable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: [])</span></span>
<span class="line"><span style="color: #F6F6F4">    )</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: viewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Error</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Previewable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">error</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">URLError</span><span style="color: #F6F6F4">(.notConnectedToInternet))</span></span>
<span class="line"><span style="color: #F6F6F4">    )</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: viewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Loading</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Previewable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">delay</span><span style="color: #F6F6F4">: .</span><span style="color: #97E1F1">seconds</span><span style="color: #F6F6F4">(</span><span style="color: #BF9EEE">3600</span><span style="color: #F6F6F4">))</span></span>
<span class="line"><span style="color: #F6F6F4">    )</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: viewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:18px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">And the tests check how the state changes for success and failure:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
struct ProductViewModelTests {
    @Test func loadStoresProducts() async {
        let viewModel = ProductViewModel(
            productService: MockProductService(products: Product.samples)
        )
        await viewModel.load()
        #expect(viewModel.state == .loaded(Product.samples))
    }

    @Test func loadFailureSetsErrorState() async {
        let viewModel = ProductViewModel(
            productService: MockProductService(error: URLError(.timedOut))
        )
        await viewModel.load()
        #expect(viewModel.state == .failed("Couldn't load products."))
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductViewModelTests</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Test</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadStoresProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">products</span><span style="color: #F6F6F4">: Product.samples)</span></span>
<span class="line"><span style="color: #F6F6F4">        )</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">#expect</span><span style="color: #F6F6F4">(viewModel.state </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">loaded</span><span style="color: #F6F6F4">(Product.samples))</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Test</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadFailureSetsErrorState</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">productService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">error</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">URLError</span><span style="color: #F6F6F4">(.timedOut))</span></span>
<span class="line"><span style="color: #F6F6F4">        )</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">#expect</span><span style="color: #F6F6F4">(viewModel.state </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">failed</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Couldn&#39;t load products.</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">))</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:18px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">For <code>#expect</code> to compare states, <code>LoadingState</code> and <code>Product</code> need to be <code>Equatable</code>. No server, no network, no waiting: these tests give the same result every time.</p>



<div style="height:18px" aria-hidden="true" class="wp-block-spacer"></div>



<h4 class="wp-block-heading">Building Up: Sharing Networking Logic With an API Client</h4>



<p class="wp-block-paragraph">As your app grows, you&#8217;ll have more services: products, cart, orders. Each one builds a URL, sends a request, and decodes the JSON. The same few lines, repeated in every service. This is where services really pay off: logic that several of them need can move into one shared place.</p>



<p class="wp-block-paragraph">For networking, that&#8217;s usually a small <code>APIClient</code>:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct APIClient {
    var baseURL = URL(string: "https://api.example.com")!

    @concurrent
    func get&lt;T: Decodable>(_ path: String) async throws -> T {
        let url = baseURL.appending(path: path)
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(T.self, from: data)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">APIClient</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> baseURL </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">URL</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">string</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">https://api.example.com</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span><span style="color: #F286C4">!</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@concurrent</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">get</span><span style="color: #F6F6F4">&lt;</span><span style="color: #BF9EEE; font-style: italic">T</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Decodable</span><span style="color: #F6F6F4">&gt;(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">path</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> T {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> url </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> baseURL.</span><span style="color: #97E1F1">appending</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">path</span><span style="color: #F6F6F4">: path)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> (data, </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> URLSession.shared.</span><span style="color: #97E1F1">data</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: url)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">JSONDecoder</span><span style="color: #F6F6F4">().</span><span style="color: #97E1F1">decode</span><span style="color: #F6F6F4">(T.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: data)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Every live service uses it, and only describes <em>what</em> it fetches:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct LiveProductService: ProductService {
    private let apiClient: APIClient

    init(apiClient: APIClient = APIClient()) {
        self.apiClient = apiClient
    }

    func fetchProducts() async throws -> &#91;Product&#93; {
        try await apiClient.get("products")
    }
}

nonisolated struct LiveCartService: CartService {
    private let apiClient: APIClient

    init(apiClient: APIClient = APIClient()) {
        self.apiClient = apiClient
    }

    func fetchCart() async throws -> Cart {
        try await apiClient.get("cart")
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">LiveProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ProductService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> apiClient: APIClient</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">apiClient</span><span style="color: #F6F6F4">: APIClient </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">APIClient</span><span style="color: #F6F6F4">()) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.apiClient </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> apiClient</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> apiClient.</span><span style="color: #97E1F1">get</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">LiveCartService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">CartService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> apiClient: APIClient</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">apiClient</span><span style="color: #F6F6F4">: APIClient </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">APIClient</span><span style="color: #F6F6F4">()) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.apiClient </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> apiClient</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchCart</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> Cart {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> apiClient.</span><span style="color: #97E1F1">get</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">cart</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>


<div class="gb-container gb-container-ac9ee019">

<figure class="gb-block-image gb-block-image-eb3f8b3f"><img loading="lazy" decoding="async" width="961" height="655" class="gb-image gb-image-eb3f8b3f" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-2.webp" alt="dependency injection in swift for networking, reusable networking layer" title="dependency-injection-swift-2" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-2.webp 961w, https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-2-300x204.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-2-768x523.webp 768w" sizes="auto, (max-width: 961px) 100vw, 961px" /></figure>

</div>


<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The <em>how</em> now lives in one place. When you add authentication headers or better error handling, you change <code>APIClient</code>, and every service picks it up. Nothing changes for the view models: they still depend on <code>ProductService</code> and <code>CartService</code>, and previews and tests still use the same mocks.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">How the Layers Fit Together, and the Names I Use</h2>



<p class="wp-block-paragraph">Now that you&#8217;ve seen services, here&#8217;s how all the layers fit together. A quick note on naming: words like &#8220;view model,&#8221; &#8220;service,&#8221; and &#8220;repository&#8221; mean different things to different people, and you&#8217;ll find other definitions in other articles and codebases. A common one: many people call something a &#8220;service&#8221; that holds UI state and the methods that change it. In my naming, that&#8217;s a view model. If it holds UI state and is <code>@Observable</code> so views can react to it, it&#8217;s the UI backing layer, whatever its name says. A service, in contrast, never holds UI state.</p>



<p class="wp-block-paragraph">This is my take: the split and the names I&#8217;ve found most practical in SwiftUI apps.</p>



<figure class="wp-block-table"><table><thead><tr><th class="has-text-align-left" data-align="left">Layer</th><th class="has-text-align-left" data-align="left">Job</th><th class="has-text-align-left" data-align="left">Swift shape</th></tr></thead><tbody><tr><td class="has-text-align-left" data-align="left"><strong>View</strong></td><td class="has-text-align-left" data-align="left">Maps state to pixels, sends user intent</td><td class="has-text-align-left" data-align="left"><code>struct</code> conforming to <code>View</code></td></tr><tr><td class="has-text-align-left" data-align="left"><strong>View model</strong></td><td class="has-text-align-left" data-align="left">Owns UI state, mutates it consistently</td><td class="has-text-align-left" data-align="left"><code>@MainActor @Observable final class</code></td></tr><tr><td class="has-text-align-left" data-align="left"><strong>Model</strong></td><td class="has-text-align-left" data-align="left">Data and its intrinsic rules</td><td class="has-text-align-left" data-align="left"><code>struct</code></td></tr><tr><td class="has-text-align-left" data-align="left"><strong>Service</strong></td><td class="has-text-align-left" data-align="left">Helper worker: stateless functions for shared external dependencies like the network, file system, UserDefaults, or HealthKit. Reusable across view models, easy to mock.</td><td class="has-text-align-left" data-align="left"><code>nonisolated struct</code> with <code>@concurrent</code> functions</td></tr><tr><td class="has-text-align-left" data-align="left"><strong>Repository</strong></td><td class="has-text-align-left" data-align="left">Mutable state the UI doesn&#8217;t observe, like a cache or a token</td><td class="has-text-align-left" data-align="left"><code>actor</code> </td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><strong>The data flow:</strong> user taps → view calls a view model method → service works in the background → data comes back → view model updates UI state on the main actor → SwiftUI updates the view.</p>


<div class="gb-container gb-container-50e6208d">

<figure class="gb-block-image gb-block-image-41c21b75"><img loading="lazy" decoding="async" width="961" height="649" class="gb-image gb-image-41c21b75" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-3.webp" alt="dependency injection in swift with multiple layers of abstraction" title="dependency-injection-swift-3" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-3.webp 961w, https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-3-300x203.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/dependency-injection-swift-3-768x519.webp 768w" sizes="auto, (max-width: 961px) 100vw, 961px" /></figure>

</div>


<p class="wp-block-paragraph">The dependencies point one way: views talk to view models, and view models talk to services. A view never calls a service directly. If it did, the view model&#8217;s state would no longer reflect what happened, and the data flow would be hard to follow.</p>



<div style="height:0px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Wrap-Up</h2>



<p class="wp-block-paragraph">We started with a practical goal: view models you can preview and test without touching <code>UserDefaults</code> or the network. The tool that got us there was the Dependency Inversion Principle. The view model depends on a protocol, the live service is the default, and previews and tests pass in a mock.</p>



<p class="wp-block-paragraph">But testability wasn&#8217;t the only thing we gained along the way:</p>



<ul class="wp-block-list">
<li><strong>Reusable components.</strong> Services can be shared by several view models, and an <code>APIClient</code> keeps networking logic in one place.</li>



<li><strong>Separation of concerns.</strong> The view model manages UI state, and the service handles the shared external dependency. Each has one job.</li>



<li><strong>Clear boundaries.</strong> Dependencies point one way, from view to view model to service, so you always know where code belongs and how data flows.</li>
</ul>



<p class="wp-block-paragraph"><strong>What&#8217;s next:</strong> in this post, every dependency came in through the initializer. In a real SwiftUI app, the next question is how dependencies get from the app&#8217;s entry point to every view model that needs them, especially shared ones. That&#8217;s where the SwiftUI environment comes in, and it&#8217;s the topic of SwiftUI Dependency Injection: Initializer, Environment, or Container?</p>



<div style="height:0px" aria-hidden="true" class="wp-block-spacer"></div>



<h5 class="wp-block-heading">Further Reading</h5>



<ul class="wp-block-list">
<li><a href="https://www.swiftyplace.com/blog/swiftui-mvvm" data-type="link" data-id="https://www.swiftyplace.com/blog/swiftui-mvvm">What Is a View Model in SwiftUI, Really?</a>: the definition of the UI backing layer this post builds on.</li>



<li><a href="https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel" data-type="link" data-id="https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel">What Should Go in a View Model?</a>: how to keep view models focused on one job.</li>



<li><a href="https://www.swiftyplace.com/blog/swiftui-architecture-swift-concurrency">How I Architect SwiftUI Apps for Swift Concurrency – So the Compiler Stays Quiet</a>: @MainActor, @concurrent, and Sendable: why view models live on the main actor and services run in the background.</li>
</ul>


<div class="gb-container gb-container-c5dedc2e">
<div class="gb-container gb-container-f7a433c7">
<div class="gb-container gb-container-7a8dc7a3">

<figure class="gb-block-image gb-block-image-f04fbbad"><a href="https://www.swiftyplace.com/free-swiftui-layout-cookbook" target="_blank" rel="noopener noreferrer"><img loading="lazy" decoding="async" width="1446" height="1552" class="gb-image gb-image-f04fbbad" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp" alt="" title="lead magnet mvvm" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp 1446w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-280x300.webp 280w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-954x1024.webp 954w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-768x824.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-1431x1536.webp 1431w" sizes="auto, (max-width: 1446px) 100vw, 1446px" /></a></figure>

</div>

<div class="gb-container gb-container-98352fe6">

<h4 class="gb-headline gb-headline-4aacd94c gb-headline-text">Get the MVVM SwiftUI Cheat Sheet</h4>



<p class="gb-headline gb-headline-5e6c4f85 gb-headline-text">The cheat sheet puts the whole system on a few pages: what goes in a view model, which view owns it, how to pass it down the tree, common mistakes, and code skeletons you can copy.</p>


<div class="gb-container gb-container-34a8ad02">

<a class="gb-button gb-button-63e90e76 gb-button-blue" href="https://school.swiftyplace.com/f/swiftui-mvvm-cheat-sheet" target="_blank" rel="noopener noreferrer"><span class="gb-button-text">Get the free cheat sheet</span><span class="gb-icon"><svg aria-hidden="true" role="img" height="1em" width="1em" viewBox="0 0 256 512" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"></path></svg></span></a>

</div>
</div>
</div>
</div><p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/dependency-injection-swift">Dependency Injection in Swift: Services, Protocols, and Testable View Models</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/dependency-injection-swift/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>What Should Go in a View Model in SwiftUI?</title>
		<link>https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=mvvm-what-in-viewmodel</link>
					<comments>https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel#respond</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Mon, 26 Oct 2026 07:50:56 +0000</pubDate>
				<category><![CDATA[SwiftUI]]></category>
		<category><![CDATA[SwiftUI Architecture]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005772</guid>

					<description><![CDATA[<p>How to use MVVM with SwiftUI? A practical guide to separation of concerns in MVVM, with real examples, code smells, and a checklist you can use today.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel">What Should Go in a View Model in SwiftUI?</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Separation of concerns is the whole reason MVVM exists. Each layer gets one job: the view describes what the user sees, the view model manages the state behind it, and the layers below deal with data and the outside world. When every piece of code sits in the layer whose job it is, you know where to look, changes stay local, and one feature doesn&#8217;t break another.</p>



<p class="wp-block-paragraph">In <a href="https://www.swiftyplace.com/blog/swiftui-mvvm" data-type="link" data-id="https://www.swiftyplace.com/blog/swiftui-mvvm">What Is a View Model in SwiftUI, Really?</a>, we defined the view model&#8217;s concern: it owns UI state and is responsible for mutating that state properly and consistently.</p>



<p class="wp-block-paragraph">But adding a view model doesn&#8217;t give you separation of concerns on its own. It just gives you another place to put code. And it&#8217;s a very convenient place: it&#8217;s already connected to the view, it already has state, so one more property or one more method is always the easiest option. Do that for a few months, and the mess that used to live in your view now lives in your view model. You haven&#8217;t separated anything. You&#8217;ve moved it.</p>



<p class="wp-block-paragraph">It goes wrong in the other direction too. Logic that belongs in the view model stays in the view, and the view starts reaching in and changing the view model&#8217;s state piece by piece. Or UI code drifts into the view model, and you can no longer find what&#8217;s on screen by looking at the view.</p>



<p class="wp-block-paragraph">So once <a href="https://www.swiftyplace.com/blog/mvvm-view-model-use-case" target="_blank" data-type="link" data-id="https://www.swiftyplace.com/blog/mvvm-view-model-use-case" rel="noreferrer noopener">you&#8217;ve decided you need a view model</a>, the real question is where its boundaries are. In this post, I&#8217;ll show you how to define a view model&#8217;s job, what belongs inside it and what doesn&#8217;t, and how to spot code that sits in the wrong layer, with examples from a Markdown app, a product browser, a checkout flow, and an auth flow.</p>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Define the Job: One View Model per Use Case</h2>



<p class="wp-block-paragraph">The first decision isn&#8217;t about individual properties. It&#8217;s about the boundary of the view model itself. Before you add anything, ask: <strong>what is the job of this view model?</strong> What is it responsible for? Once that&#8217;s clear, you give it all the state, the methods, and the dependencies it needs to do that job, and nothing more.</p>



<p class="wp-block-paragraph">I think of this as scoping view models by <strong>use case</strong>: one coherent job around one set of state. The state and the methods that mutate it form a unit. They have a reason to live together. When you start adding a different set of state, with methods that have nothing to do with what&#8217;s already there, you&#8217;re mixing concerns. The view model loses its cohesion: the things inside it no longer have a logical reason to be in the same place.</p>



<p class="wp-block-paragraph">Let&#8217;s go back to the Markdown app from <a href="https://www.swiftyplace.com/blog/mvvm-view-model-use-case" data-type="link" data-id="https://www.swiftyplace.com/blog/mvvm-view-model-use-case">the previous post</a>. It has two clear use cases:</p>



<ul class="wp-block-list">
<li><strong>Themes:</strong> all available themes, which one is selected, and creating, deleting, selecting, and saving them.</li>



<li><strong>Documents:</strong> the list of Markdown documents, and loading, creating, renaming, and deleting them.</li>
</ul>



<figure class="gb-block-image gb-block-image-fb29cd7d"><img loading="lazy" decoding="async" width="2270" height="1079" class="gb-image gb-image-fb29cd7d" src="http://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1.webp" alt="markdown ios app with MVVM in SwiftUI" title="when-to-use-viewmodel-example-1-2 1" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1.webp 2270w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-300x143.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-1024x487.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-768x365.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-1536x730.webp 1536w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-2048x973.webp 2048w" sizes="auto, (max-width: 2270px) 100vw, 2270px" /></figure>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">So the app gets two view models, one for each:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Observable class ThemeStore {
    private(set) var themes: &#91;Theme&#93; = []
    private(set) var selectedThemeID: Theme.ID?

    var selectedTheme: Theme? {
        themes.first { $0.id == selectedThemeID }
    }

    func createTheme(name: String, colors: ThemeColors) async { /* ... */ }
    func deleteTheme(_ theme: Theme) async { /* ... */ }
    func selectTheme(_ theme: Theme) async { /* ... */ }
}

@Observable class DocumentStore {
    private(set) var documents: &#91;MarkdownDocument&#93; = []

    func loadDocuments() async { /* ... */ }
    func createDocument(title: String) async { /* ... */ }
    func renameDocument(_ document: MarkdownDocument, to title: String) async { /* ... */ }
    func deleteDocument(_ document: MarkdownDocument) async { /* ... */ }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Observable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ThemeStore</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> themes: &#91;Theme&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedThemeID: Theme.ID</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedTheme: Theme</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        themes.</span><span style="color: #97E1F1">first</span><span style="color: #F6F6F4"> { </span><span style="color: #BF9EEE; font-style: italic">$0</span><span style="color: #F6F6F4">.id </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> selectedThemeID }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">createTheme</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">name</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">, </span><span style="color: #62E884; font-style: italic">colors</span><span style="color: #F6F6F4">: ThemeColors) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">deleteTheme</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">theme</span><span style="color: #F6F6F4">: Theme) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">selectTheme</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">theme</span><span style="color: #F6F6F4">: Theme) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">@Observable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">DocumentStore</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> documents: &#91;MarkdownDocument&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadDocuments</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">createDocument</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">title</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">renameDocument</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">document</span><span style="color: #F6F6F4">: MarkdownDocument, </span><span style="color: #62E884">to</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">title</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">deleteDocument</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">document</span><span style="color: #F6F6F4">: MarkdownDocument) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Why not put both into one <code>AppViewModel</code>? Because themes and documents don&#8217;t change together. Deleting a document has nothing to do with which theme is selected. Creating a theme has nothing to do with the document list. They&#8217;re separate state, with separate mutations, so they live in separate view models.</p>



<p class="wp-block-paragraph">And here&#8217;s the interesting part: both are used on the same screen. The document detail view shows the document&#8217;s content and renders it with the selected theme. That&#8217;s fine. A view can use more than one view model:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ContentView: View {
    @State private var themeStore = ThemeStore()  
    @State private var documentStore = DocumentStore()
    @State private var selectedFile: MarkdownFile? = nil
    
    var body: some View {
        NavigationSplitView {
            MarkdownFilesListView(documentStore: documentStore, 
                                  selection: $selectedFile)
        } detail: {
            if let selectedFile {
                MarkdownDetailView(themeStore: themeStore,
                                   markdown: selectedFile)
            } else {
                PlaceholderView()
            }
        }
        .task {
           await themeStore.load()
           await documentStore.load()
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ContentView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> themeStore </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ThemeStore</span><span style="color: #F6F6F4">()  </span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> documentStore </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">DocumentStore</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedFile: MarkdownFile</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span></span>
<span class="line"><span style="color: #F6F6F4">    </span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">NavigationSplitView</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">MarkdownFilesListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">documentStore</span><span style="color: #F6F6F4">: documentStore, </span></span>
<span class="line"><span style="color: #F6F6F4">                                  </span><span style="color: #97E1F1">selection</span><span style="color: #F6F6F4">: $selectedFile)</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #97E1F1">detail</span><span style="color: #F6F6F4">: {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> selectedFile {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">MarkdownDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">themeStore</span><span style="color: #F6F6F4">: themeStore,</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                                   </span><span style="color: #97E1F1">markdown</span><span style="color: #F6F6F4">: selectedFile)</span></span>
<span class="line"><span style="color: #F6F6F4">            } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">PlaceholderView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">task</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">           </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> themeStore.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">           </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> documentStore.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This is also why &#8220;one view model per screen&#8221; doesn&#8217;t work. The <code>MarkdownDetailView</code> screen needs state from two use cases. A single screen view model would either duplicate the theme state, or pull both use cases into one class. The use case decides the boundary, not the screen.</p>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Avoid the God View Model</h2>



<p class="wp-block-paragraph">Think about it this way. If you avoid spaghetti code in your views, but then move everything into one giant view model, you haven&#8217;t solved anything. You&#8217;ve just moved the mess around. And it&#8217;s not a new problem. Whatever separation technique you use, there&#8217;s always a risk of creating something massive. UIKit had the infamous massive view controller. In SwiftUI, we can end up with massive views. And we can absolutely end up with massive view models too.</p>



<p class="wp-block-paragraph">The opposite of a focused view model is the <strong>god view model: one huge class that holds everything</strong>. Themes, documents, settings, user info, sync status, all in one place, with dozens of methods and a file you scroll forever.</p>



<p class="wp-block-paragraph">Nobody sets out to write one. It happens one feature at a time. The view model is already there and already connected to the views, so adding <em>&#8220;just one more thing&#8221;</em> is the easiest option. Repeat that for a few months, and you have a god view model.</p>



<p class="wp-block-paragraph">Here&#8217;s why it hurts:</p>



<ul class="wp-block-list">
<li><strong>Hard to understand:</strong> you can&#8217;t tell what any part of it is responsible for, or which methods touch which state.</li>



<li><strong>Changes ripple:</strong> a change for one feature can break another, because they share the same class and sometimes the same state.</li>



<li><strong>Everything depends on everything:</strong> every view that uses it is connected to all of it, even the parts it doesn&#8217;t need.</li>



<li><strong>Hard to test:</strong> to test one behavior, you have to set up the whole thing.</li>



<li><strong>Merge conflicts:</strong> if everyone on the team works in the same file, you&#8217;ll be resolving conflicts constantly.</li>
</ul>



<p class="wp-block-paragraph">The fix is the same idea as above: split by use case. Not by line count, and not by screen. If a group of state and the methods that change it form their own job, they get their own view model.</p>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">UI State: Collect State That Changes Together</h2>



<p class="wp-block-paragraph">Once you know the use case, the same idea works on a smaller scale. Inside a view model, group the state that changes together.</p>



<p class="wp-block-paragraph">Loading is the classic example. When <code>DocumentStore</code> loads documents, three pieces of state are involved:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>private(set) var isLoading = false
private(set) var errorMessage: String?
private(set) var documents: &#91;MarkdownDocument&#93; = []</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> errorMessage: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> documents: &#91;MarkdownDocument&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span></code></pre></div>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">These always change together. Start loading: set <code>isLoading</code>, clear the error. Success: store the documents, reset <code>isLoading</code>. Failure: set the error, reset <code>isLoading</code>. Every mutation has to remember to update all three correctly. Forget one, and you&#8217;re in a state that shouldn&#8217;t exist, like a spinner and an error message at the same time.</p>



<p class="wp-block-paragraph">So at the very least, keep these properties together and change them together in one place. Even better, model them as one value with an enum:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>enum LoadingState&lt;Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(String)
}

private(set) var state: LoadingState&lt;&#91;MarkdownDocument&#93;> = .idle</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">enum</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">LoadingState</span><span style="color: #F6F6F4">&lt;</span><span style="color: #BF9EEE; font-style: italic">Value</span><span style="color: #F6F6F4">&gt; {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> idle</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> loading</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> loaded(</span><span style="color: #97E1F1; font-style: italic">Value</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> failed(</span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> state: LoadingState&lt;&#91;MarkdownDocument&#93;&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .idle</span></span></code></pre></div>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Now only valid states are possible, and every transition is a single assignment. The same idea applies elsewhere: form fields belong together with their validation errors, and a selection belongs together with the list it refers to.</p>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What Belongs in a View Model</h2>



<p class="wp-block-paragraph">With the boundaries in place, here&#8217;s what actually goes inside.</p>



<p class="wp-block-paragraph"><strong>UI state.</strong> The data your views need to render: the list of documents, the selected theme, the loading state, form input.</p>



<p class="wp-block-paragraph"><strong>Derived state.</strong> Values that can be calculated from other state should be computed properties, not stored copies. <code>selectedTheme</code> is computed from <code>themes</code> and <code>selectedThemeID</code>. If you stored it as a separate property, you&#8217;d have to update it every time either of those changes, and sooner or later you&#8217;d forget. Computed properties have nothing to keep in sync. Other examples: <code>canSubmit</code> in a form, <code>formattedTotal</code> in a cart.</p>



<p class="wp-block-paragraph"><strong>Mutations.</strong> The methods responsible for changing the state properly and consistently. Creating a theme means adding it, selecting it, and saving, every time, in that order. That sequence lives in one method, so no view has to know about it.</p>



<p class="wp-block-paragraph"><strong>Display formatting.</strong> Turning raw data into something ready to show: a formatted price, a relative date, a title combined from several fields. This is about <em>what</em> to show. <em>How</em> it looks is still up to the view.</p>



<p class="wp-block-paragraph"><strong>Gatekeeping.</strong> Answering &#8220;is this action allowed right now?&#8221; as state, for example <code>canSubmit</code> or <code>canDeleteTheme</code>. The view model decides whether something is allowed. The view decides that &#8220;not allowed&#8221; means a disabled button.</p>



<p class="wp-block-paragraph"><strong>Dependencies.</strong> The services the view model needs to do its job, like the file service for <code>ThemeStore</code> or a networking service for loading products. The view model calls them. It doesn&#8217;t do their work itself.</p>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What Doesn&#8217;t Belong (and Where It Goes)</h2>



<p class="wp-block-paragraph">Just as important is what stays out. Each of these has a better home. This is my personal recommendation that is clear and relatively straightforward to follow.</p>



<p class="wp-block-paragraph"><strong>Layout, styling, and any UI code → the view.</strong> Fonts, colors, spacing, animations, which view to show for which state. The view model never says &#8220;show a red label here,&#8221; and it never contains view code itself. More on that in the next section.</p>



<p class="wp-block-paragraph"><strong>Data structures and intrinsic rules → the model.</strong> A <code>Theme</code> struct, a <code>MarkdownDocument</code>, the rule for what makes a valid email address or a maximum cart quantity. These rules belong to the data itself, no matter which view model uses them.</p>



<p class="wp-block-paragraph"><strong>State from another use case → another view model.</strong> Even if a view needs it. The document detail view needs the selected theme, but that doesn&#8217;t mean the theme belongs in <code>DocumentStore</code>. The view simply uses both.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h5 class="wp-block-heading">Services: The Layer Behind the View Model</h5>



<p class="wp-block-paragraph">A view model&#8217;s job is managing UI state. But to do that job, it often needs things outside your own code: the network, the file system,&nbsp;<code>UserDefaults</code>, the Keychain, HealthKit. These are shared external dependencies, and working with them is a different job. So that work goes into a service.</p>



<p class="wp-block-paragraph">A service is a helper worker:</p>



<ul class="wp-block-list">
<li><strong>Stateless:</strong>&nbsp;plain functions that do one kind of work, like fetching products or saving themes.</li>



<li><strong>Off the main actor:</strong>&nbsp;heavy work like requests and decoding doesn&#8217;t block the UI.</li>



<li><strong>Reusable:</strong>&nbsp;the same API call can serve several view models, like the cart and checkout.</li>



<li><strong>Replaceable:</strong>&nbsp;the view model depends on a protocol, so you can inject a mock for previews and tests.</li>
</ul>



<p class="wp-block-paragraph">The view model keeps the state and decides when to call the service. The service does the work and returns the data. I go deeper into services, repositories, and dependency injection in a separate blog post.</p>



<div style="height:23px" aria-hidden="true" class="wp-block-spacer"></div>



<h5 class="wp-block-heading">Navigation: Coordinators for Presentation State</h5>



<p class="wp-block-paragraph">Simple navigation stays in the view: the user taps a product, and a detail view is pushed. But sometimes logic decides where to go.</p>



<p class="wp-block-paragraph">Say a shopping app checks for discount offers when the user opens the shop. If the server returns an offer, a sheet should open. That&#8217;s three concerns at once:</p>



<ul class="wp-block-list">
<li><strong>Fetching the offers</strong>&nbsp;is networking → a service.</li>



<li><strong>Holding the offers</strong>&nbsp;is UI state → the view model.</li>



<li><strong>Opening the sheet</strong>&nbsp;is presentation state → neither of them.</li>
</ul>



<p class="wp-block-paragraph">Presentation state gets its own owner: a&nbsp;<strong>coordinator</strong>. It&#8217;s an&nbsp;<code>@Observable</code>&nbsp;class that owns the navigation path and which sheet is shown. It holds only state, never view code. The view model decides&nbsp;<em>that</em>&nbsp;the offer should be shown and tells the coordinator; the view maps the coordinator&#8217;s state to the actual sheet.</p>



<p class="wp-block-paragraph">Coordinators pay off in larger, more complex apps, especially when several developers work on different features. And there, they should&nbsp;<strong>always be nested</strong>: an app coordinator for the top-level flows, and a child coordinator per feature, like shop or checkout. A single coordinator for the whole app turns into the same god object we just got rid of: it knows every screen, every feature depends on it, and every change touches it. With nested coordinators, each flow owns only its own navigation. The concerns stay separated, dependencies stay local, and features are easy to split into modules.</p>



<p class="wp-block-paragraph">This pattern is often called MVVM-C, and there are different ways to implement it. I cover how I use it in detail in another blog post: Navigation with Coordinators.</p>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Going Too Far: UI Code in a View Model</h2>



<p class="wp-block-paragraph">It&#8217;s possible to swing too far in the other direction, and move things into the view model that clearly belong in the view. The most extreme version is putting actual view code into it.</p>



<p class="wp-block-paragraph">Imagine an <code>AuthManager</code> with three states: loading, an error, or a logged-in user. Now we add a <code>@ViewBuilder</code> function that returns the screen for each state:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class AuthManager {
    private(set) var state: AuthState = .loading

    @ViewBuilder
    func screenComponent() -> some View {
        switch state {
        case .loading: ProgressView()
        case .failed(let message): Text(message)
        case .loggedIn(let user): Text("Welcome, (user.name)")
        }
    }
}

struct HomeView: View {
    let authManager: AuthManager

    var body: some View {
        authManager.screenComponent()
            .padding()
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">AuthManager</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> state: AuthState </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .loading</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@ViewBuilder</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">screenComponent</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> state {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .loading</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProgressView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">failed</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> message)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(message)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">loggedIn</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> user)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Welcome, (user.name)</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">HomeView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> authManager: AuthManager</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        authManager.</span><span style="color: #97E1F1">screenComponent</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">padding</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This works. But the view is now &#8220;unemployed.&#8221; It&#8217;s just an empty container that does nothing. And that causes real problems:</p>



<ul class="wp-block-list">
<li><strong>You can&#8217;t find your UI.</strong> Open <code>HomeView</code>, and you can&#8217;t tell what&#8217;s on screen. If someone asks you to make the welcome text bold, it&#8217;s not there. You jump to <code>AuthManager</code>, look through the cases, figure out which one to change, and bounce between files for a one-line UI tweak.</li>



<li><strong>Reusability drops.</strong> Before, the coupling went one way: the view knew about the view model. Now the view model also knows how the UI looks. If you want the same auth logic with a different design somewhere else, you can&#8217;t, because the UI is baked into the observable.</li>



<li><strong>Testing gets hard.</strong> Testing a normal view model is easy: is the state loading, yes or no? Testing a function that returns <code>some View</code> means dealing with SwiftUI&#8217;s opaque types, a deeply nested <code>ConditionalContent</code> type that&#8217;s hard to assert on, and possibly spinning up SwiftUI just to run a unit test.</li>
</ul>



<p class="wp-block-paragraph">So here&#8217;s my rule that&#8217;s simple enough to actually follow: <strong>no UI code in your view models.</strong> No <code>some View</code>, no <code>@ViewBuilder</code>, nothing that describes how to draw pixels.</p>



<p class="wp-block-paragraph">That gives each side a clear job:</p>



<ul class="wp-block-list">
<li><strong>The view maps state to pixels.</strong> It decides what to show for a given state and how to style it.</li>



<li><strong>The view model manages what state the app is in.</strong> It handles the data, the logic, the service calls, and the state transitions. If a request fails with a &#8220;forbidden&#8221; error, the view model stores that error. Whether it shows up as an alert, an inline message, or a banner is the view&#8217;s decision.</li>
</ul>



<p class="wp-block-paragraph">With that rule, the switch moves back where it belongs:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct HomeView: View {
    let authManager: AuthManager

    var body: some View {
        switch authManager.state {
        case .loading:
            ProgressView()
        case .failed(let message):
            Text(message).foregroundStyle(.red)
        case .loggedIn(let user):
            Text("Welcome, (user.name)").font(.title)
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">HomeView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> authManager: AuthManager</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> authManager.state {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .loading</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">ProgressView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">failed</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> message)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(message).</span><span style="color: #97E1F1">foregroundStyle</span><span style="color: #F6F6F4">(.red)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">loggedIn</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> user)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Welcome, (user.name)</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">).</span><span style="color: #97E1F1">font</span><span style="color: #F6F6F4">(.title)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Now everything you see on screen is in the view, and <code>AuthManager</code> is back to being a plain class you can test with simple assertions.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Example: What Stays Out of a Product Browser</h2>



<p class="wp-block-paragraph">Let&#8217;s put this into practice with a product browser. You can search through a list of products and apply filters to narrow down the results. There are network requests, filtering logic, loading states, and error handling, so all of that moves into a <code>ProductViewModel</code>.</p>


<div class="gb-container gb-container-cc9d7384">

<figure class="gb-block-image gb-block-image-b10aaae7"><img loading="lazy" decoding="async" width="1154" height="1152" class="gb-image gb-image-b10aaae7" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-productbrowser.webp" alt="" title="mvvm-what-inside-productbrowser" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-productbrowser.webp 1154w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-productbrowser-300x300.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-productbrowser-1024x1022.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-productbrowser-150x150.webp 150w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-productbrowser-768x767.webp 768w" sizes="auto, (max-width: 1154px) 100vw, 1154px" /></figure>

</div>


<p class="wp-block-paragraph">First, define the job. This view model fetches products, updates the list when the search or filter changes, manages the loading state, and keeps an error message the UI can show. To do that job, it needs the search query, the selected filter, the methods that perform the fetch, and the service that does the network request in the background:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class ProductViewModel {
    var searchQuery = ""
    var selectedFilter: ProductFilter = .all
    private(set) var state: LoadingState&lt;&#91;Product&#93;> = .idle

    private let productService: ProductService

    func fetchProducts() async { /* ... */ }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> searchQuery </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedFilter: ProductFilter </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .all</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> state: LoadingState&lt;&#91;Product&#93;&gt; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .idle</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> productService: ProductService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">That&#8217;s it. Now the interesting part is what I deliberately kept in the view:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ProductBrowserView: View {
    @State private var viewModel = ProductViewModel()
    @State private var isFilterSheetShown = false
    @State private var isGridLayout = true
    @State private var path = NavigationPath()

    // ...
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductBrowserView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductViewModel</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isFilterSheetShown </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isGridLayout </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">true</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> path </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">NavigationPath</span><span style="color: #F6F6F4">()</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// ...</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<ul class="wp-block-list">
<li><strong>Whether the filter sheet is shown.</strong> Does the view model need to know a sheet is open to fetch products? No. It cares about <em>which</em> filter is selected, not whether the UI for choosing it is visible.</li>



<li><strong>Grid or list layout.</strong> A display preference. It has nothing to do with fetching or managing products.</li>



<li><strong>The navigation path.</strong> Does the view model need to know the user tapped through to a detail screen? No. If the detail screen needs more data, that&#8217;s a different job, with a different lifetime, and only needed once the user is actually there.</li>
</ul>



<p class="wp-block-paragraph">All three are about what&#8217;s currently on screen: display state, navigation state, interaction state. They have a different concern than the view model&#8217;s job, so they stay in the view.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Example: When Logic Should Move In</h2>



<p class="wp-block-paragraph">The opposite happens too: code that lives in the view but belongs to the view model&#8217;s job.</p>


<div class="gb-container gb-container-062ded38">

<figure class="gb-block-image gb-block-image-7698b8dc"><img loading="lazy" decoding="async" width="566" height="1152" class="gb-image gb-image-7698b8dc" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-shopping-cart.webp" alt="" title="mvvm-what-inside-shopping-cart" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-shopping-cart.webp 566w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-shopping-cart-147x300.webp 147w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-what-inside-shopping-cart-503x1024.webp 503w" sizes="auto, (max-width: 566px) 100vw, 566px" /></figure>

</div>


<p class="wp-block-paragraph">Say a shopping app has a checkout flow. A&nbsp;<code>CheckoutViewModel</code>&nbsp;loads the final cart so the user can review it: the items, subtotal, discount, shipping, taxes, and total. Its job is to manage those totals.</p>



<p class="wp-block-paragraph">Then a new feature request comes in: the user should be able to enter a promo code. In a first version, the promo code logic lives in the view. To make that work, the view model&#8217;s totals have to be settable from outside:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class CheckoutViewModel {
    var subtotal: Decimal = 0
    var discount: Decimal = 0
    var shipping: Decimal = 0
    var taxes: Decimal = 0
    var total: Decimal = 0
    // ...
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CheckoutViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> subtotal: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> discount: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> shipping: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> taxes: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> total: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// ...</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">And the view does the work:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct CheckoutView: View {
    var checkoutViewModel: CheckoutViewModel
    @State private var promoCode = ""
    @State private var promoError: String?

    var body: some View {
        Form {
            CartSummarySection(checkoutViewModel: checkoutViewModel)
            Section("Promo Code") {
                TextField("Enter code", text: $promoCode)
                if let promoError {
                    Text(promoError).foregroundStyle(.red)
                }
                Button("Apply") { applyPromoCode() }
            }
        }
    }

    // <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Business logic in the view
    private func applyPromoCode() {
        guard promoCode == promoCode.uppercased() else {
            promoError = "Promo codes use capital letters only."
            return
        }

        if promoCode == "SAVE10" {
            // Reads subtotal, changes discount...
            checkoutViewModel.discount = checkoutViewModel.subtotal * 0.1
            // ...then reads three more values to change total
            checkoutViewModel.total = checkoutViewModel.subtotal
                - checkoutViewModel.discount
                + checkoutViewModel.shipping
                + checkoutViewModel.taxes // <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> taxes were calculated before the discount
            promoError = nil
        } else {
            promoError = "This code isn't valid."
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">CheckoutView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> checkoutViewModel: CheckoutViewModel</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> promoCode </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> promoError: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">Form</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">CartSummarySection</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">checkoutViewModel</span><span style="color: #F6F6F4">: checkoutViewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Section</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Promo Code</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">TextField</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Enter code</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">text</span><span style="color: #F6F6F4">: $promoCode)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> promoError {</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(promoError).</span><span style="color: #97E1F1">foregroundStyle</span><span style="color: #F6F6F4">(.red)</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">Button</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Apply</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) { </span><span style="color: #97E1F1">applyPromoCode</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// &#x26a0; Business logic in the view</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">applyPromoCode</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">guard</span><span style="color: #F6F6F4"> promoCode </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> promoCode.</span><span style="color: #97E1F1">uppercased</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            promoError </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Promo codes use capital letters only.</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">return</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> promoCode </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">SAVE10</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4"> {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">            </span><span style="color: #7B7F8B">// Reads subtotal, changes discount...</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">            checkoutViewModel.discount </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> checkoutViewModel.subtotal </span><span style="color: #F286C4">*</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0.1</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">            </span><span style="color: #7B7F8B">// ...then reads three more values to change total</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">            checkoutViewModel.total </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> checkoutViewModel.subtotal</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">-</span><span style="color: #F6F6F4"> checkoutViewModel.discount</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">+</span><span style="color: #F6F6F4"> checkoutViewModel.shipping</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">+</span><span style="color: #F6F6F4"> checkoutViewModel.taxes </span><span style="color: #7B7F8B">// &#x26a0; taxes were calculated before the discount</span></span>
<span class="line"><span style="color: #F6F6F4">            promoError </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            promoError </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">This code isn&#39;t valid.</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:31px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Look at what&nbsp;<code>applyPromoCode()</code>&nbsp;does with the view model. It reads&nbsp;<code>subtotal</code>, changes&nbsp;<code>discount</code>, then reads&nbsp;<code>subtotal</code>,&nbsp;<code>discount</code>,&nbsp;<code>shipping</code>, and&nbsp;<code>taxes</code>&nbsp;again to change&nbsp;<code>total</code>. The view has to know how the totals depend on each other, and it gets it wrong: the taxes were calculated on the old subtotal, so the user sees a wrong total. And the valid codes are hardcoded in the view, when only the server knows which codes exist.</p>



<p class="wp-block-paragraph">That&#8217;s the code smell to watch for:&nbsp;<strong>if your view reads one property from the view model, changes another, then reads a third, it&#8217;s working with the view model&#8217;s internals.</strong>&nbsp;That&#8217;s a sign the view is handling a responsibility that belongs to the view model.</p>



<p class="wp-block-paragraph">And look at the job again: managing the totals. Applying a promo code is part of that job. So it moves in, behind a single method. The server validates the code and returns the recalculated cart, and the view model updates all totals together:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class CheckoutViewModel {
    private(set) var items: &#91;CartItem&#93; = []
    private(set) var subtotal: Decimal = 0
    private(set) var discount: Decimal = 0
    private(set) var shipping: Decimal = 0
    private(set) var taxes: Decimal = 0
    private(set) var total: Decimal = 0

    private(set) var isApplyingPromoCode = false
    private(set) var promoCodeError: String?

    private let cartService: CartService

    init(cartService: CartService) {
        self.cartService = cartService
    }

    func loadCart() async {
        // Fetch the final cart and call update(with:)
    }

    func applyPromoCode(_ code: String) async {
        isApplyingPromoCode = true
        promoCodeError = nil
        defer { isApplyingPromoCode = false }

        do {
            // The server validates the code and returns recalculated totals
            let cart = try await cartService.applyPromoCode(code)
            update(with: cart)
        } catch {
            promoCodeError = "This promo code isn't valid."
        }
    }

    // All totals change together, in one place
    private func update(with cart: Cart) {
        items = cart.items
        subtotal = cart.subtotal
        discount = cart.discount
        shipping = cart.shipping
        taxes = cart.taxes
        total = cart.total
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CheckoutViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> items: &#91;CartItem&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> subtotal: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> discount: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> shipping: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> taxes: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> total: Decimal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isApplyingPromoCode </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> promoCodeError: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> cartService: CartService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">cartService</span><span style="color: #F6F6F4">: CartService) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.cartService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> cartService</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadCart</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #7B7F8B">// Fetch the final cart and call update(with:)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">applyPromoCode</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">code</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">        isApplyingPromoCode </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">true</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">        promoCodeError </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">defer</span><span style="color: #F6F6F4"> { isApplyingPromoCode </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span><span style="color: #F6F6F4"> }</span></span>
<span class="line cbp-line-highlight"></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">do</span><span style="color: #F6F6F4"> {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">            </span><span style="color: #7B7F8B">// The server validates the code and returns recalculated totals</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> cart </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> cartService.</span><span style="color: #97E1F1">applyPromoCode</span><span style="color: #F6F6F4">(code)</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">update</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">with</span><span style="color: #F6F6F4">: cart)</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">catch</span><span style="color: #F6F6F4"> {</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">            promoCodeError </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">This promo code isn&#39;t valid.</span><span style="color: #DEE492">&quot;</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">        }</span></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// All totals change together, in one place</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">update</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">with</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">cart</span><span style="color: #F6F6F4">: Cart) {</span></span>
<span class="line"><span style="color: #F6F6F4">        items </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> cart.items</span></span>
<span class="line"><span style="color: #F6F6F4">        subtotal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> cart.subtotal</span></span>
<span class="line"><span style="color: #F6F6F4">        discount </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> cart.discount</span></span>
<span class="line"><span style="color: #F6F6F4">        shipping </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> cart.shipping</span></span>
<span class="line"><span style="color: #F6F6F4">        taxes </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> cart.taxes</span></span>
<span class="line"><span style="color: #F6F6F4">        total </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> cart.total</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:31px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The totals are&nbsp;<code>private(set)</code>&nbsp;again, so no view can change one without the others. The view just hands off the code and shows the state:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>Button("Apply Promo Code") {
    Task { await checkoutViewModel.applyPromoCode(promoCode) }
}
.disabled(checkoutViewModel.isApplyingPromoCode)</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">Button</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Apply Promo Code</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">Task</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> checkoutViewModel.</span><span style="color: #97E1F1">applyPromoCode</span><span style="color: #F6F6F4">(promoCode) }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"><span style="color: #F6F6F4">.</span><span style="color: #97E1F1">disabled</span><span style="color: #F6F6F4">(checkoutViewModel.isApplyingPromoCode)</span></span></code></pre></div>



<div style="height:31px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Notice that&nbsp;<code>promoCode</code>&nbsp;stays in the view as&nbsp;<code>@State</code>. It&#8217;s plain text input that only this view uses, until the user taps Apply. What moved is the logic that changes the totals.</p>



<p class="wp-block-paragraph">And there&#8217;s a bonus: if promo codes can be applied in several places in the app, that logic now lives in one place instead of being duplicated across views.</p>



<div style="height:31px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">A Quick Checklist</h2>



<p class="wp-block-paragraph">When you&#8217;re about to add something to a view model, run through these questions:</p>



<ol class="wp-block-list">
<li><strong>What&#8217;s the job of this view model?</strong> Does the new code belong to that job? If it&#8217;s part of a different job, it belongs in a different view model.</li>



<li><strong>Does it own or change UI state?</strong> If not, move it to the view, the model, or a service.</li>



<li><strong>Is it UI code?</strong> No <code>some View</code>, no <code>@ViewBuilder</code>. That stays in the view.</li>



<li><strong>Does it change together with the rest of the state here?</strong> If yes, keep it close, and update it in the same place.</li>



<li><strong>Can it be derived instead of stored?</strong> Then make it a computed property.</li>



<li><strong>Are several related properties really one state?</strong> Then consider modeling them as one enum.</li>



<li><strong>Is the view reaching into several view model properties to make one change?</strong> Then that logic belongs in the view model, behind a single method.</li>
</ol>



<div style="height:31px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Wrapping Up</h2>



<p class="wp-block-paragraph">A good view model is focused. It has one clear job, holds the state that changes together, and is responsible for mutating that state properly and consistently. Everything else has a better home: UI code and display state in the view, rules in the model, heavy work in services, and other jobs in their own view models.</p>



<p class="wp-block-paragraph">This isn&#8217;t a decision you make once. As your app grows and new features arrive, keep asking whether new code belongs to an existing view model&#8217;s job. Sometimes logic needs to move out, like the filter sheet in the product browser. Sometimes it needs to move in, like the promo code in checkout. The line stays the same: the view model manages state and logic, and the view maps that state to what the user sees.</p>


<div class="gb-container gb-container-c5dedc2e">
<div class="gb-container gb-container-f7a433c7">
<div class="gb-container gb-container-7a8dc7a3">

<figure class="gb-block-image gb-block-image-f04fbbad"><a href="https://www.swiftyplace.com/free-swiftui-layout-cookbook" target="_blank" rel="noopener noreferrer"><img loading="lazy" decoding="async" width="1446" height="1552" class="gb-image gb-image-f04fbbad" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp" alt="" title="lead magnet mvvm" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp 1446w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-280x300.webp 280w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-954x1024.webp 954w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-768x824.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-1431x1536.webp 1431w" sizes="auto, (max-width: 1446px) 100vw, 1446px" /></a></figure>

</div>

<div class="gb-container gb-container-98352fe6">

<h4 class="gb-headline gb-headline-4aacd94c gb-headline-text">Get the MVVM SwiftUI Cheat Sheet</h4>



<p class="gb-headline gb-headline-5e6c4f85 gb-headline-text">The cheat sheet puts the whole system on a few pages: what goes in a view model, which view owns it, how to pass it down the tree, common mistakes, and code skeletons you can copy.</p>


<div class="gb-container gb-container-34a8ad02">

<a class="gb-button gb-button-63e90e76 gb-button-blue" href="https://school.swiftyplace.com/f/swiftui-mvvm-cheat-sheet" target="_blank" rel="noopener noreferrer"><span class="gb-button-text">Get the free cheat sheet</span><span class="gb-icon"><svg aria-hidden="true" role="img" height="1em" width="1em" viewBox="0 0 256 512" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"></path></svg></span></a>

</div>
</div>
</div>
</div>


<div style="height:45px" aria-hidden="true" class="wp-block-spacer"></div>



<h5 class="wp-block-heading">Further Reading</h5>



<ul class="wp-block-list">
<li class="has-f-6-f-6-f-4-color has-text-color"><a href="https://www.swiftyplace.com/blog/swiftui-mvvm" data-type="link" data-id="https://www.swiftyplace.com/blog/swiftui-mvvm">What Is a View Model in SwiftUI, Really?</a></li>



<li class="has-f-6-f-6-f-4-color has-text-color"><a href="https://www.swiftyplace.com/blog/mvvm-view-model-use-case">When to Use a View Model in SwiftUI (and When It’s Overkill)</a></li>
</ul>



<p class="wp-block-paragraph"></p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel">What Should Go in a View Model in SwiftUI?</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/mvvm-what-in-viewmodel/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>When to Use a View Model in SwiftUI (and When It&#8217;s Overkill)</title>
		<link>https://www.swiftyplace.com/blog/mvvm-view-model-use-case?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=mvvm-view-model-use-case</link>
					<comments>https://www.swiftyplace.com/blog/mvvm-view-model-use-case#respond</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Mon, 19 Oct 2026 07:58:16 +0000</pubDate>
				<category><![CDATA[SwiftUI]]></category>
		<category><![CDATA[SwiftUI Architecture]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005743</guid>

					<description><![CDATA[<p>A view model is a powerful tool, but it’s also an extra file and an extra layer of abstraction. Through four practical examples, we’ll look at when a view model earns its place and when it’s just overblown, unnecessary code that gets in your way.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/mvvm-view-model-use-case">When to Use a View Model in SwiftUI (and When It&#8217;s Overkill)</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In the previous post, we looked at <a href="https://www.swiftyplace.com/blog/swiftui-mvvm" data-type="post" data-id="1005742">what a view model</a> actually is: a UI backing layer that owns UI state and is responsible for mutating that state properly and consistently.</p>



<p class="wp-block-paragraph">That&#8217;s a powerful tool. But it&#8217;s also an extra file and an extra layer of abstraction. So the question isn&#8217;t &#8220;should I always use a view model?&#8221; The question is: when do the benefits outweigh the cost?</p>



<p class="wp-block-paragraph">In this post, I&#8217;ll walk you through four examples. I&#8217;ll show simple and complex versions so you can see exactly where a view model starts to make sense and where it becomes an overblown, unnecessary thing that gets in your way.</p>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Cost and the Benefit of View Model in MVVM</h2>



<p class="wp-block-paragraph">Before we get into the examples, let&#8217;s be clear about what we&#8217;re weighing.</p>



<p class="wp-block-paragraph"><strong>The cost of a view model:</strong></p>



<ul class="wp-block-list">
<li>Another file and another type to maintain</li>



<li>Your code is spread over two places</li>



<li>To find out what a view shows, you sometimes have to jump to another file</li>



<li>More objects for SwiftUI to manage</li>
</ul>



<p class="wp-block-paragraph"><strong>The benefit of a view model:</strong></p>



<ul class="wp-block-list">
<li>Views stay simple and focused on UI</li>



<li>State mutations live in one place and happen the same way every time</li>



<li>Several views can share the same state and the same methods to change it</li>



<li>State can outlive a single view</li>
</ul>



<p class="wp-block-paragraph">If a view model doesn&#8217;t deliver any of those benefits, you&#8217;re only paying the cost. Let&#8217;s see what that looks like in practice.</p>



<div style="height:45px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Example 1: From a Color Picker to a Theme Editor</h2>



<h3 class="wp-block-heading">The Simple Version</h3>



<p class="wp-block-paragraph">The first example is a simple document app. Each document is a short text snippet with its own color, and when you open a document, that color is used for the background. At the bottom, there&#8217;s a color picker for switching between colors.</p>


<div class="gb-container gb-container-0b026956">

<figure class="gb-block-image gb-block-image-15addc30"><img loading="lazy" decoding="async" width="1161" height="1143" class="gb-image gb-image-15addc30" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1.webp" alt="iOS app with document app. Simple version with a color picker for the document background. No view model needed. MVVM would be overkill in this example." title="when-to-use-viewmodel-example-1" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1.webp 1161w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-300x295.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-1024x1008.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-768x756.webp 768w" sizes="auto, (max-width: 1161px) 100vw, 1161px" /></figure>

</div>


<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Say you&#8217;re building an early MVP and want to keep things easy. You might just hardcode the available colors:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);--cbp-line-highlight-color:rgba(251, 251, 239, 0.2);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ColorPickerView: View {
    @Binding var selectedColor: Color

    let colors: &#91;Color&#93; = &#91;.red, .orange, .yellow, .green, .blue, .purple&#93;

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack {
                ForEach(colors, id: .self) { color in
                    Circle()
                        .fill(color)
                        .frame(width: 32, height: 32)
                        .onTapGesture { selectedColor = color }
                }
            }
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ColorPickerView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Binding</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedColor: Color</span></span>
<span class="line"></span>
<span class="line cbp-line-highlight"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> colors: &#91;Color&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> &#91;.red, .orange, .yellow, .green, .blue, .purple&#93;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">ScrollView</span><span style="color: #F6F6F4">(.horizontal, </span><span style="color: #97E1F1">showsIndicators</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE">false</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">HStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">ForEach</span><span style="color: #F6F6F4">(colors, </span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: .</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { color </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">Circle</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                        .</span><span style="color: #97E1F1">fill</span><span style="color: #F6F6F4">(color)</span></span>
<span class="line"><span style="color: #F6F6F4">                        .</span><span style="color: #97E1F1">frame</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">width</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE">32</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">height</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE">32</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">                        .</span><span style="color: #97E1F1">onTapGesture</span><span style="color: #F6F6F4"> { selectedColor </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> color }</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The view has an array of colors and a horizontal scroll view that iterates over them. Tap a color, and it becomes the selected color through the binding. This view is almost entirely UI. There&#8217;s no logic here.</p>



<p class="wp-block-paragraph">So what would a view model look like? The only thing we could move into it is the array of colors:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Observable
class ColorPickerStore {
    let colors: &#91;Color&#93; = &#91;.red, .orange, .yellow, .green, .blue, .purple&#93;
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ColorPickerStore</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> colors: &#91;Color&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> &#91;.red, .orange, .yellow, .green, .blue, .purple&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">That&#8217;s it: an observable class holding a hardcoded array. It doesn&#8217;t own any state that changes, and it doesn&#8217;t take any work off the view. It just adds an extra layer and splits the code across two files. This is definitely overkill. I would stick with the view-only solution here.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">When the Requirements Change</h3>



<p class="wp-block-paragraph">Now let&#8217;s keep working on the app. Instead of using color just for the background, I want to render Markdown, with colors for the headings, the code blocks, and the document background. I also want users to create their own themes with custom colors and fonts, and those themes need to be saved to disk and loaded again the next time.</p>



<figure class="gb-block-image gb-block-image-31765222"><img loading="lazy" decoding="async" width="2270" height="1079" class="gb-image gb-image-31765222" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1.webp" alt="iOS app with a Markdown document app in SwiftUI. Use of view model to store and retrieve themes for markdown rendering. Showing the MVVM pattern in SwiftUI" title="when-to-use-viewmodel-example-1-2 1" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1.webp 2270w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-300x143.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-1024x487.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-768x365.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-1536x730.webp 1536w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-1-2-1-2048x973.webp 2048w" sizes="auto, (max-width: 2270px) 100vw, 2270px" /></figure>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The theme picker view itself still doesn&#8217;t have much logic. It&#8217;s mostly UI code: show this list, show this button, use this styling. The real work happens in a store (aka the view model):</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class ThemeStore {
    private(set) var themes: &#91;Theme&#93; = []
    private(set) var selectedThemeID: Theme.ID?

    var selectedTheme: Theme? {
        themes.first { $0.id == selectedThemeID }
    }

    private let fileService: ThemeFileService

    init(fileService: ThemeFileService = ThemeFileService()) {
        self.fileService = fileService
    }

    func loadThemes() async {
        themes = (try? await fileService.load()) ?? Theme.defaults
    }

    func createTheme(name: String, colors: ThemeColors) async {
        let theme = Theme(name: name, colors: colors)
        themes.append(theme)
        selectedThemeID = theme.id
        await save()
    }

    func deleteTheme(_ theme: Theme) async {
        themes.removeAll { $0.id == theme.id }
        await save()
    }

    func selectTheme(_ theme: Theme) async {
        selectedThemeID = theme.id
        await save()
    }

    private func save() async {
        try? await fileService.save(themes)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ThemeStore</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> themes: &#91;Theme&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedThemeID: Theme.ID</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedTheme: Theme</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        themes.</span><span style="color: #97E1F1">first</span><span style="color: #F6F6F4"> { </span><span style="color: #BF9EEE; font-style: italic">$0</span><span style="color: #F6F6F4">.id </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> selectedThemeID }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> fileService: ThemeFileService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">fileService</span><span style="color: #F6F6F4">: ThemeFileService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ThemeFileService</span><span style="color: #F6F6F4">()) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.fileService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> fileService</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadThemes</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        themes </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> (</span><span style="color: #F286C4">try?</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> fileService.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">()) </span><span style="color: #F286C4">??</span><span style="color: #F6F6F4"> Theme.defaults</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">createTheme</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">name</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">, </span><span style="color: #62E884; font-style: italic">colors</span><span style="color: #F6F6F4">: ThemeColors) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> theme </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">Theme</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">name</span><span style="color: #F6F6F4">: name, </span><span style="color: #97E1F1">colors</span><span style="color: #F6F6F4">: colors)</span></span>
<span class="line"><span style="color: #F6F6F4">        themes.</span><span style="color: #97E1F1">append</span><span style="color: #F6F6F4">(theme)</span></span>
<span class="line"><span style="color: #F6F6F4">        selectedThemeID </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> theme.id</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">save</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">deleteTheme</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">theme</span><span style="color: #F6F6F4">: Theme) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        themes.</span><span style="color: #97E1F1">removeAll</span><span style="color: #F6F6F4"> { </span><span style="color: #BF9EEE; font-style: italic">$0</span><span style="color: #F6F6F4">.id </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> theme.id }</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">save</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">selectTheme</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">theme</span><span style="color: #F6F6F4">: Theme) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        selectedThemeID </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> theme.id</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">save</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">save</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">try?</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> fileService.</span><span style="color: #97E1F1">save</span><span style="color: #F6F6F4">(themes)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Now there&#8217;s real state to manage: all the themes and which one is selected. And there are real mutations. When I create a theme, it needs to be added, selected, and saved. When I delete one, the UI state has to update and the change has to be saved for next time. Because every mutation goes through the store, each one ends with the same <code>save()</code> call, and nothing gets forgotten.</p>



<p class="wp-block-paragraph">Notice that the store doesn&#8217;t do the file handling itself. Encoding and decoding all the themes into a JSON file in the documents directory is quite a bit of code, and it has nothing to do with UI state. So that work goes to a separate service that runs in the background. That keeps the store small and focused. I cover this layer in more detail in another post about Services and Repositories.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Where the Store Lives</h3>



<p class="wp-block-paragraph">There&#8217;s one more reason the store pays off here. The themes are created and managed in the theme picker sheet, but the selected theme is also needed in the view that renders every Markdown file.</p>



<p class="wp-block-paragraph">If the store lived inside the sheet or the detail view, it would be created and thrown away every time I navigate back and forth, and the themes would have to be reloaded each time. So I place it at the app&#8217;s top level, where it lives as long as the themes are needed, and pass it to every view that uses it.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ContentView: View {
    // owned high in the view tree, 
    // lifes for the duration of the app
    @State private var themeStore = ThemeStore()  
    @State private var markdownFileStore = MarkdownFileStore()
    @State private var selectedFile: MarkdownFile? = nil
    
    var body: some View {
        NavigationSplitView {
            MarkdownFilesListView(markdownFileStore: markdownFileStore, 
                                  selection: $selectedFile)
        } detail: {
            if let selectedFile {
                MarkdownDetailView(themeStore: themeStore,
                                   markdown: selectedFile)
            } else {
                PlaceholderView()
            }
        }
        .task {
           await themeStore.load()
           await markdownFileStore.load()
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ContentView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// owned high in the view tree, </span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// lifes for the duration of the app</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> themeStore </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ThemeStore</span><span style="color: #F6F6F4">()  </span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> markdownFileStore </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">MarkdownFileStore</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedFile: MarkdownFile</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span></span>
<span class="line"><span style="color: #F6F6F4">    </span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">NavigationSplitView</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">MarkdownFilesListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">markdownFileStore</span><span style="color: #F6F6F4">: markdownFileStore, </span></span>
<span class="line"><span style="color: #F6F6F4">                                  </span><span style="color: #97E1F1">selection</span><span style="color: #F6F6F4">: $selectedFile)</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #97E1F1">detail</span><span style="color: #F6F6F4">: {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> selectedFile {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">MarkdownDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">themeStore</span><span style="color: #F6F6F4">: themeStore,</span></span>
<span class="line"><span style="color: #F6F6F4">                                   </span><span style="color: #97E1F1">markdown</span><span style="color: #F6F6F4">: selectedFile)</span></span>
<span class="line"><span style="color: #F6F6F4">            } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">PlaceholderView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">task</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">           </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> themeStore.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">           </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> markdownFileStore.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">And because all the logic is in one neat package, I can move it wherever I need it later. If the loading code were scattered across views, every change would mean moving a lot more code around.</p>



<p class="wp-block-paragraph"><strong>Takeaway:</strong> same feature, different answer. The simple color picker had nothing to manage. The theme editor has state that changes, mutations that need to happen consistently, and several views that need the same state. That&#8217;s exactly when a view model earns its place.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Example 2: The About View</h2>



<p class="wp-block-paragraph">The second example is an About screen. It shows the app name, the version number, and a short &#8220;About us&#8221; text. These are plain strings that I just need to display.</p>


<div class="gb-container gb-container-18d4519a">

<figure class="gb-block-image gb-block-image-31c2b68e"><img loading="lazy" decoding="async" width="581" height="1143" class="gb-image gb-image-31c2b68e" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-2.webp" alt="ios app with about screen showing simple strings. implementated with a simple swiftui view without a view model" title="when-to-use-viewmodel-example-2" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-2.webp 581w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-2-152x300.webp 152w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-2-521x1024.webp 521w" sizes="auto, (max-width: 581px) 100vw, 581px" /></figure>

</div>


<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct AboutView: View {
    let appName = "Markdown Notes"
    let version = "1.2.0"
    let aboutText = "Made with love by a small team."

    var body: some View {
        VStack(spacing: 8) {
            Text(appName).font(.title)
            Text("Version (version)").foregroundStyle(.secondary)
            Text(aboutText)
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">AboutView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> appName </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Markdown Notes</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> version </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">1.2.0</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> aboutText </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Made with love by a small team.</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">VStack</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">spacing</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE">8</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(appName).</span><span style="color: #97E1F1">font</span><span style="color: #F6F6F4">(.title)</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Version (version)</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">).</span><span style="color: #97E1F1">foregroundStyle</span><span style="color: #F6F6F4">(.secondary)</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(aboutText)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Do I need a view model for this? There&#8217;s no logic and nothing to do. I could keep these values as properties, like above, or write them straight into the view body. Both are simple enough.</p>



<p class="wp-block-paragraph">If I introduced a view model, I&#8217;d be moving these strings into a separate layer and adding abstraction for no reason. And it actively hurts you. One of the drawbacks of too much abstraction is that you have to jump around in your code. If I want to know what a label shows, I have to go to another type, look it up there, and come back.</p>



<p class="wp-block-paragraph">There&#8217;s also nothing to manage. These are constants. Nothing mutates during runtime, so there&#8217;s no reason for a class, and certainly no reason for <code>@Observable</code>.</p>



<p class="wp-block-paragraph"><strong>Takeaway:</strong> if nothing changes, there&#8217;s nothing for a view model to manage.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Example 3: A Custom Button Component</h2>



<p class="wp-block-paragraph">The third example is a reusable button. Teams often want preset configurations for buttons: a trash can icon paired with red, a certain padding, a certain style.</p>


<div class="gb-container gb-container-f9480854">

<figure class="gb-block-image gb-block-image-af319e8a"><img loading="lazy" decoding="async" width="540" height="1050" class="gb-image gb-image-af319e8a" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-3.webp" alt="ios app with differnt button configurations" title="when-to-use-viewmodel-example-3" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-3.webp 540w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-3-154x300.webp 154w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-3-527x1024.webp 527w" sizes="auto, (max-width: 540px) 100vw, 540px" /></figure>

</div>


<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">The View Model Version</h3>



<p class="wp-block-paragraph">What happens if you use a view model for this configuration? Every button creates and owns its own view model, with properties for the title, color, icon, and action:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@Observable
class CustomButtonViewModel {
    var title: String
    var color: Color
    var icon: String
    var action: () -> Void
    // ...
}

CustomButton(viewModel: CustomButtonViewModel(
    title: "Delete", color: .red, icon: "trash", action: deleteItem
))</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CustomButtonViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> title: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> color: Color</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> icon: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> action: () </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Void</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// ...</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">CustomButton</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">CustomButtonViewModel</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">title</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Delete</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">color</span><span style="color: #F6F6F4">: .red, </span><span style="color: #97E1F1">icon</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">trash</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">action</span><span style="color: #F6F6F4">: deleteItem</span></span>
<span class="line"><span style="color: #F6F6F4">))</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This is overkill. There&#8217;s no state that changes over time and no logic to manage. I&#8217;m just adding another layer with more code.</p>



<p class="wp-block-paragraph">And it gets worse:</p>



<ul class="wp-block-list">
<li><strong>More work for SwiftUI:</strong> every button now has an extra object that has to be allocated and tracked.</li>



<li><strong>A second source of truth:</strong> the button owns its view model, so if you pass a new configuration for the same button, it won&#8217;t update.</li>



<li><strong>More verbose usage:</strong> creating a view model just to show a button is a lot of ceremony.</li>
</ul>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">The View-Only Version</h3>



<p class="wp-block-paragraph">Compare that to the simple version, where the button just has normal properties:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>CustomButton(title: "Delete", color: .red, icon: "trash", action: deleteItem)</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">CustomButton</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">title</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Delete</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">color</span><span style="color: #F6F6F4">: .red, </span><span style="color: #97E1F1">icon</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">trash</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">action</span><span style="color: #F6F6F4">: deleteItem)</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This is the simplest, most SwiftUI-like version, and it does pretty much everything you need.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">For Design Systems: A Styling Struct</h3>



<p class="wp-block-paragraph">Now, what if you really want the same button with different preset stylings? Maybe you&#8217;re working with a strict design system that you built together with a UI designer. One clean way to handle this is a struct that bundles the styling properties together:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ButtonStyling {
    let foregroundColor: Color
    let backgroundColor: Color
    let icon: String?
    let font: Font

    init(foregroundColor: Color, backgroundColor: Color,
         icon: String? = nil, font: Font = .body) {
        self.foregroundColor = foregroundColor
        self.backgroundColor = backgroundColor
        self.icon = icon
        self.font = font
    }

    // presets
    static let primary = ButtonStyling(foregroundColor: .white, backgroundColor: .blue)
    static let destructive = ButtonStyling(foregroundColor: .white, backgroundColor: .red, icon: "trash")
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ButtonStyling</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> foregroundColor: Color</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> backgroundColor: Color</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> icon: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> font: Font</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">foregroundColor</span><span style="color: #F6F6F4">: Color, </span><span style="color: #62E884; font-style: italic">backgroundColor</span><span style="color: #F6F6F4">: Color,</span></span>
<span class="line"><span style="color: #F6F6F4">         </span><span style="color: #62E884; font-style: italic">icon</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span><span style="color: #F6F6F4">, </span><span style="color: #62E884; font-style: italic">font</span><span style="color: #F6F6F4">: Font </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .body) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.foregroundColor </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> foregroundColor</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.backgroundColor </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> backgroundColor</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.icon </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> icon</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.font </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> font</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// presets</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">static</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> primary </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ButtonStyling</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">foregroundColor</span><span style="color: #F6F6F4">: .white, </span><span style="color: #97E1F1">backgroundColor</span><span style="color: #F6F6F4">: .blue)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">static</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> destructive </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ButtonStyling</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">foregroundColor</span><span style="color: #F6F6F4">: .white, </span><span style="color: #97E1F1">backgroundColor</span><span style="color: #F6F6F4">: .red, </span><span style="color: #97E1F1">icon</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">trash</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">These are all constants, because you pass them in once. The initializer gives you sensible defaults, and static presets cover the common cases. Using it stays just as easy:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>CustomButton(title: "Continue", style: .primary) { next() }
CustomButton(title: "Delete", style: .destructive) { deleteItem() }</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">CustomButton</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">title</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Continue</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">style</span><span style="color: #F6F6F4">: .primary) { </span><span style="color: #97E1F1">next</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #97E1F1">CustomButton</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">title</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Delete</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">style</span><span style="color: #F6F6F4">: .destructive) { </span><span style="color: #97E1F1">deleteItem</span><span style="color: #F6F6F4">() }</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The overhead is small. Instead of passing every property individually, you bundle them into a plain struct.</p>



<p class="wp-block-paragraph"><strong>Takeaway:</strong> configuration is not state (that changes during runtime). Reach for plain properties or a struct, not an observable.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Example 4: From Login to Account Creation</h2>



<h3 class="wp-block-heading">The Simple Version</h3>



<p class="wp-block-paragraph">For the last example, let&#8217;s say we start with a basic login view: two state properties, two text fields, and a login button. There&#8217;s no real logic yet, and the view is very simple. No view model needed. I can keep working on it since it&#8217;s still in the early stages of development.</p>


<div class="gb-container gb-container-8f624582">
<div class="gb-container gb-container-b5d7b9a2">

<figure class="gb-block-image gb-block-image-550bad59"><img loading="lazy" decoding="async" width="540" height="998" class="gb-image gb-image-550bad59" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-4.webp" alt="ios app with simple login view in SwiftUI" title="when-to-use-viewmodel-example-4" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-4.webp 540w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-4-162x300.webp 162w" sizes="auto, (max-width: 540px) 100vw, 540px" /></figure>

</div>
</div>


<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct LoginView: View {
    @State private var email = ""
    @State private var password = ""

    var body: some View {
        VStack {
            TextField("Email", text: $email)
            SecureField("Password", text: $password)
            Button("Login") {
                if !email.isEmpty &amp;&amp; !password.isEmpty {
                    print("Try login...")
                }
            }
        }
        .padding()
        .textFieldStyle(.roundedBorder)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">LoginView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> email </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> password </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">VStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">TextField</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Email</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">text</span><span style="color: #F6F6F4">: $email)</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">SecureField</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Password</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">text</span><span style="color: #F6F6F4">: $password)</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Button</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Login</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">!</span><span style="color: #F6F6F4">email.</span><span style="color: #BF9EEE">isEmpty</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">&amp;&amp;</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">!</span><span style="color: #F6F6F4">password.</span><span style="color: #BF9EEE">isEmpty</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">print</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Try login...</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">padding</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">textFieldStyle</span><span style="color: #F6F6F4">(.roundedBorder)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">When the Requirements Change</h3>



<p class="wp-block-paragraph">At some point, you have to add a <strong>Create Account</strong> feature. Now you need:</p>



<ul class="wp-block-list">
<li><strong>Live validation</strong>, showing error messages while the user types their email</li>



<li><strong>Password strength feedback</strong>, with a red, yellow, or green indicator</li>



<li><strong>Loading states</strong>, because pressing <strong>Create Account</strong> sends an API request. While it&#8217;s running, the button has to be disabled so the user can&#8217;t create the same account twice. Otherwise the first request succeeds and the second fails with &#8220;email already taken.&#8221;</li>



<li><strong>Proper error handling</strong>, so the user always knows what&#8217;s going on</li>
</ul>



<p class="wp-block-paragraph">That&#8217;s a lot more logic than the simple login view. Especially once network requests are involved, a small view easily grows and grows until it becomes unmanageable.</p>


<div class="gb-container gb-container-e5bef27d">

<figure class="gb-block-image gb-block-image-0f3ca2ea"><img loading="lazy" decoding="async" width="1014" height="989" class="gb-image gb-image-0f3ca2ea" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-5.webp" alt="ios app with create account view using a view model in SwiftUI" title="when-to-use-viewmodel-example-5" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-5.webp 1014w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-5-300x293.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/when-to-use-viewmodel-example-5-768x749.webp 768w" sizes="auto, (max-width: 1014px) 100vw, 1014px" /></figure>

</div>


<p class="wp-block-paragraph">This is where a view model becomes a state engine. It manages all the state and the mutations, so the view can stay simple:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class AuthViewModel {
    var email = ""
    var password = ""
    private(set) var isLoading = false
    private(set) var errorMessage: String?
    private(set) var isLoggedIn = false

    private let authService: AuthService

    init(authService: AuthService = AuthService()) {
        self.authService = authService
    }

    var emailError: String? { /* live email validation */ }
    var passwordStrength: PasswordStrength { /* weak, medium, strong */ }

    var canSubmit: Bool {
        emailError == nil &amp;&amp; passwordStrength != .weak &amp;&amp; !isLoading
    }

    func createAccount() async {
        await perform { try await self.authService.createAccount(email: self.email, password: self.password) }
    }

    func login() async {
        await perform { try await self.authService.login(email: self.email, password: self.password) }
    }

    // One place that runs every auth request the same way
    private func perform(_ request: () async throws -> Void) async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }
        do {
            try await request()
            isLoggedIn = true
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">AuthViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> email </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> password </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> errorMessage: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isLoggedIn </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> authService: AuthService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">authService</span><span style="color: #F6F6F4">: AuthService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">AuthService</span><span style="color: #F6F6F4">()) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.authService </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> authService</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> emailError: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* live email validation */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> passwordStrength: PasswordStrength { </span><span style="color: #7B7F8B">/* weak, medium, strong */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> canSubmit: </span><span style="color: #97E1F1; font-style: italic">Bool</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        emailError </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">&amp;&amp;</span><span style="color: #F6F6F4"> passwordStrength </span><span style="color: #F286C4">!=</span><span style="color: #F6F6F4"> .weak </span><span style="color: #F286C4">&amp;&amp;</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">!</span><span style="color: #F6F6F4">isLoading</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">createAccount</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">perform</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.authService.</span><span style="color: #97E1F1">createAccount</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">email</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.email, </span><span style="color: #97E1F1">password</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.password) }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">login</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">perform</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.authService.</span><span style="color: #97E1F1">login</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">email</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.email, </span><span style="color: #97E1F1">password</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.password) }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// One place that runs every auth request the same way</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">perform</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">request</span><span style="color: #F6F6F4">: () </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Void</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">true</span></span>
<span class="line"><span style="color: #F6F6F4">        errorMessage </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">defer</span><span style="color: #F6F6F4"> { isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">do</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">request</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">            isLoggedIn </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">true</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">catch</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            errorMessage </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> error.localizedDescription</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This is a shortened version. In a real app, the validation alone adds up to a lot more code. But even with all of that, the view stays small. It manages its text fields, builds the button, and calls the view model. Disabling the button is one line using <code>canSubmit</code>.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Sharing State Mutations Across Views</h3>



<p class="wp-block-paragraph">There&#8217;s another big advantage once this logic lives in a view model: <strong>it&#8217;s no longer tied to a single view</strong>.</p>



<p class="wp-block-paragraph">The create account screen isn&#8217;t the only place that deals with authentication. I also have a login screen, and maybe a sign-in prompt somewhere else in the app with a completely different look. All of these views work with the same auth state: whether a request is loading, whether there&#8217;s an error, whether the user is logged in. And login state is usually something you need app-wide anyway.</p>



<p class="wp-block-paragraph">Instead of writing that logic again in every view, I can simply pass the view model along and call its methods:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct LoginView: View {
    @Bindable var auth: AuthViewModel

    var body: some View {
        VStack {
            TextField("Email", text: $auth.email)
            SecureField("Password", text: $auth.password)

            Button("Log In") {
                Task { await auth.login() }
            }
            .disabled(!auth.canSubmit)

            if let error = auth.errorMessage {
                Text(error).foregroundStyle(.red)
            }
        }
    }
}

struct CreateAccountView: View {
    @Bindable var auth: AuthViewModel

    var body: some View {
        // Different layout, same view model
        Button("Create Account") {
            Task { await auth.createAccount() }
        }
        .disabled(!auth.canSubmit)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">LoginView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Bindable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> auth: AuthViewModel</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">VStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">TextField</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Email</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">text</span><span style="color: #F6F6F4">: $auth.email)</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">SecureField</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Password</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">text</span><span style="color: #F6F6F4">: $auth.password)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Button</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Log In</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">Task</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> auth.</span><span style="color: #97E1F1">login</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">disabled</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">!</span><span style="color: #F6F6F4">auth.canSubmit)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> error </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> auth.errorMessage {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(error).</span><span style="color: #97E1F1">foregroundStyle</span><span style="color: #F6F6F4">(.red)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">CreateAccountView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Bindable</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> auth: AuthViewModel</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #7B7F8B">// Different layout, same view model</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">Button</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Create Account</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Task</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> auth.</span><span style="color: #97E1F1">createAccount</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">disabled</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">!</span><span style="color: #F6F6F4">auth.canSubmit)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Both views look different, but neither of them knows how logging in or creating an account works. They call <code>login()</code> or <code>createAccount()</code>, and the view model takes care of the rest.</p>



<p class="wp-block-paragraph">This matters because these are complex state mutations. Setting the loading state, clearing old errors, handling a failed request, and updating the logged-in state all have to happen in the right order. If every view did this on its own, I&#8217;d end up duplicating very similar code that mutates the same state, and sooner or later one copy would behave slightly differently from the others. <strong>With the view model, there&#8217;s one place responsible for how the auth state changes, and every view shares those same methods</strong>.</p>



<p class="wp-block-paragraph">This is also why the view model isn&#8217;t owned by any one of these screens. It&#8217;s owned higher up, where all of them can reach it. I explain this approach in more detail in <a href="https://www.swiftyplace.com/blog/swiftui-mvvm" data-type="post" data-id="1005742">What Is a View Model in SwiftUI, Really?</a>, under &#8220;scope by state, not by screen.&#8221;</p>



<p class="wp-block-paragraph"><strong>Takeaway:</strong> complex state mutations and state shared across views are exactly what view models are for.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Pattern Behind the Examples</h2>



<p class="wp-block-paragraph">If you look at the four examples side by side, a pattern shows up. Whenever there was real state to manage and real mutations to get right, the view model paid off. Whenever there wasn&#8217;t, it only added cost.</p>



<p class="wp-block-paragraph"><strong>A view model is usually worth it when:</strong></p>



<ul class="wp-block-list">
<li>The view needs real logic: validation, persistence, networking</li>



<li>State changes over time, and those changes need to be coordinated</li>



<li>Several views work with the same state and should share the methods that change it</li>



<li>The state needs to outlive the view that displays it</li>
</ul>



<p class="wp-block-paragraph"><strong>It&#8217;s usually overkill when:</strong></p>



<ul class="wp-block-list">
<li>The view only displays constants or simple values</li>



<li>You&#8217;re configuring a reusable component</li>



<li>The view model would hold little more than the properties the view already has</li>
</ul>



<p class="wp-block-paragraph">Notice that none of these points is about the size of the screen or how important it feels. It all comes back to the definition: a view model owns UI state and is responsible for mutating it properly. If there&#8217;s no state to own and no mutations to get right, there&#8217;s no job for a view model to do.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Start Simple, Refactor When It Grows</h2>



<p class="wp-block-paragraph">You might have noticed that two of the examples started without a view model and only needed one later. That&#8217;s very typical. Most view models aren&#8217;t planned up front. They show up when a view starts collecting logic.</p>



<p class="wp-block-paragraph">So you don&#8217;t need to decide on day one. Start with the simplest version that works, and keep an eye out for these signs:</p>



<ul class="wp-block-list">
<li>The view body gets long, and most of it isn&#8217;t UI</li>



<li>State mutations are scattered across subviews, or duplicated in slightly different versions</li>



<li>It&#8217;s getting hard to follow how and when the state changes</li>



<li>A second view suddenly needs the same state</li>
</ul>



<p class="wp-block-paragraph">When you see them, that&#8217;s the moment to move the state and its mutations into a view model. I&#8217;ll walk through that refactoring step by step in Refactoring to a View Model When Complexity Grows.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Wrapping Up</h2>



<p class="wp-block-paragraph">There&#8217;s no solution that fits all. Whether you use a view model in a given situation is really up to you, and it comes down to one question: is this extra layer worth it here?</p>



<p class="wp-block-paragraph">You don&#8217;t have to use one. A view-only solution is often the simplest and best choice. But when your views start collecting state and mutations that need to happen consistently, a view model is a very good tool for managing that complexity.</p>



<p class="wp-block-paragraph">Once you&#8217;ve decided you need one, the next question is what actually belongs inside it. That&#8217;s what we&#8217;ll look at in What Should Go in a View Model?</p>


<div class="gb-container gb-container-c5dedc2e">
<div class="gb-container gb-container-f7a433c7">
<div class="gb-container gb-container-7a8dc7a3">

<figure class="gb-block-image gb-block-image-f04fbbad"><a href="https://www.swiftyplace.com/free-swiftui-layout-cookbook" target="_blank" rel="noopener noreferrer"><img loading="lazy" decoding="async" width="1446" height="1552" class="gb-image gb-image-f04fbbad" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp" alt="" title="lead magnet mvvm" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp 1446w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-280x300.webp 280w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-954x1024.webp 954w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-768x824.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-1431x1536.webp 1431w" sizes="auto, (max-width: 1446px) 100vw, 1446px" /></a></figure>

</div>

<div class="gb-container gb-container-98352fe6">

<h4 class="gb-headline gb-headline-4aacd94c gb-headline-text">Get the MVVM SwiftUI Cheat Sheet</h4>



<p class="gb-headline gb-headline-5e6c4f85 gb-headline-text">The cheat sheet puts the whole system on a few pages: what goes in a view model, which view owns it, how to pass it down the tree, common mistakes, and code skeletons you can copy.</p>


<div class="gb-container gb-container-34a8ad02">

<a class="gb-button gb-button-63e90e76 gb-button-blue" href="https://school.swiftyplace.com/f/swiftui-mvvm-cheat-sheet" target="_blank" rel="noopener noreferrer"><span class="gb-button-text">Get the free cheat sheet</span><span class="gb-icon"><svg aria-hidden="true" role="img" height="1em" width="1em" viewBox="0 0 256 512" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"></path></svg></span></a>

</div>
</div>
</div>
</div><p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/mvvm-view-model-use-case">When to Use a View Model in SwiftUI (and When It&#8217;s Overkill)</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/mvvm-view-model-use-case/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>What Is a View Model in SwiftUI MVVM, Really</title>
		<link>https://www.swiftyplace.com/blog/swiftui-mvvm?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=swiftui-mvvm</link>
					<comments>https://www.swiftyplace.com/blog/swiftui-mvvm#respond</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Fri, 16 Oct 2026 11:02:43 +0000</pubDate>
				<category><![CDATA[SwiftUI]]></category>
		<category><![CDATA[SwiftUI Architecture]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005742</guid>

					<description><![CDATA[<p>Learn how to apply MVVM in SwiftUI. Find out what a view model actually is, what it’s responsible for, and how it fits between your views and your data. A clear, practical introduction with real SwiftUI code examples to help you keep your views simple and your app state under control.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/swiftui-mvvm">What Is a View Model in SwiftUI MVVM, Really</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">If you&#8217;ve spent any time reading about SwiftUI architecture, you&#8217;ve probably run into the term &#8220;view model.&#8221; And you&#8217;ve probably noticed that it means different things to different people. Some tutorials add a view model to every single view without ever explaining why. Others say SwiftUI doesn&#8217;t need view models at all, because &#8220;the view is already the view model.&#8221;</p>



<p class="wp-block-paragraph">So what is a view model, really? In this post, I want to give you a clear, practical definition that you can apply to your own code. Here&#8217;s the main rule that I find most practical, so you have something to hold on to:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>A view model owns UI state and is responsible for mutating that state properly and consistently.</strong></p>
</blockquote>



<p class="wp-block-paragraph">Notice what&#8217;s not in that sentence: a single view. A view model isn&#8217;t tied to one view. It can support one view, an entire screen, or several views that share the same state. (Other people might say otherwise, but I am giving you the system that works best for me)</p>



<p class="wp-block-paragraph">That responsibility is the whole point. In SwiftUI, state drives the UI. When something on screen is wrong, the cause is almost always state that&#8217;s wrong. And state goes wrong because the code that mutates it is messy: scattered across views, duplicated in slightly different versions, or running steps in the wrong order. A view model gives those mutations one home, so they happen the same way every time.</p>



<p class="wp-block-paragraph">That&#8217;s it. Everything else in this post explains what that means, why it&#8217;s useful, and where the boundaries are.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Where MVVM Comes From</h2>



<p class="wp-block-paragraph">MVVM stands for <a href="https://en.wikipedia.org/wiki/Model–view–viewmodel" data-type="link" data-id="https://en.wikipedia.org/wiki/Model–view–viewmodel" target="_blank" rel="noopener">Model-View-ViewModel.</a> It was introduced in 2005 by Microsoft architects Ken Cooper and Ted Peters. I don&#8217;t usually spend much time on history, but here it matters because understanding where the pattern came from helps us understand what it&#8217;s actually trying to do.</p>



<p class="wp-block-paragraph">MVVM was born in Windows Presentation Foundation, or WPF. The UI in WPF is written in XAML, a declarative markup language, and it relies heavily on data bindings and reactive updates. In other words, it&#8217;s state-driven, declarative programming. Sound familiar? That&#8217;s very close to how SwiftUI works.</p>



<p class="wp-block-paragraph">So what problem was MVVM designed to solve? It came down to people. In WPF, teams naturally split into two groups: UI-facing developers, who Microsoft called &#8220;designer developers,&#8221; and logic developers working behind the scenes. The challenge was letting these two groups work in parallel without stepping on each other&#8217;s toes.</p>



<p class="wp-block-paragraph">What kept happening was that logic crept into the view: network requests, formatting, data transformations. The view code became cluttered and hard to maintain, and when everyone has to work in the same file, collaboration breaks down fast.</p>



<p class="wp-block-paragraph">The solution was to pull that complex logic out of the view and into a separate layer. The view was left with nothing but declarative UI, and the logic developers had their own layer to work in. That separate layer is the view model.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Backing Layer: A Presentation Layer Pattern</h2>



<p class="wp-block-paragraph">The best way to think about a view model is as the backing layer behind your UI. You have the view on one side and your data on the other, and you place an intermediate layer in between. That layer handles the more complex concerns: state management, formatting, anything you don&#8217;t want cluttering your view code.</p>



<p class="wp-block-paragraph">I like to compare it to an old-school calculator. On the front, you see the buttons and the display. That&#8217;s your view. Now imagine you unscrew the case and look inside: the battery, the circuit board, some cables. That&#8217;s your backing layer. As a user, you just want to press buttons and get results. You don&#8217;t want to see or deal with the internals. A view model works the same way: it keeps the inner workings hidden behind a clean surface.</p>



<figure class="gb-block-image gb-block-image-7f65d7e5"><img loading="lazy" decoding="async" width="978" height="314" class="gb-image gb-image-7f65d7e5" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-layers-calculator.webp" alt="" title="mvvm-layers-calculator" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-layers-calculator.webp 978w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-layers-calculator-300x96.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-layers-calculator-768x247.webp 768w" sizes="auto, (max-width: 978px) 100vw, 978px" /></figure>



<div style="height:16px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">That&#8217;s why I often prefer the term <strong>UI backing layer</strong> over &#8220;view model.&#8221; It describes what the thing actually does.</p>



<p class="wp-block-paragraph">This also leads to an important clarification. Because we&#8217;re talking about layers, you&#8217;ll sometimes hear MVVM described as a &#8220;UI architecture pattern.&#8221; That can be misleading, because it gives the impression that MVVM is a full app architecture. It&#8217;s not. <strong>MVVM is a presentation layer pattern.</strong> It&#8217;s about the view and the code that directly backs it.</p>



<ul class="wp-block-list">
<li>It&#8217;s not about visual design, like layout, colors, or components.</li>



<li>It doesn&#8217;t tell you how to structure your whole app. Networking, persistence, navigation, and dependencies need their own layers and patterns.</li>



<li>At its core, it&#8217;s about separation of concerns within the UI layer.</li>
</ul>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Three Layers</h2>



<p class="wp-block-paragraph">Let&#8217;s look at each part of the pattern and what it&#8217;s responsible for.</p>


<div class="gb-container gb-container-187b7444">

<figure class="gb-block-image gb-block-image-c09039d3"><img loading="lazy" decoding="async" width="966" height="674" class="gb-image gb-image-c09039d3" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-layers-flow.webp" alt="representation layer in SwiftUI apps with MVVM" title="mvvm-layers-flow" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-layers-flow.webp 966w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-layers-flow-300x209.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/mvvm-layers-flow-768x536.webp 768w" sizes="auto, (max-width: 966px) 100vw, 966px" /></figure>

</div>


<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">The View</h3>



<p class="wp-block-paragraph">The view is the UI layer. In SwiftUI, these are your <code>View</code> structs, and their job is to <strong>take state and decide what to display.</strong> Logged in? Show the main content. Logged out? Show the login screen.</p>



<p class="wp-block-paragraph">Views also handle the finer details of presentation. Is a button enabled or disabled? What text goes into a label? What font, what position, what layout? These are all view concerns. The view is the part that makes things look good.</p>



<p class="wp-block-paragraph">Views also receive user interactions. When someone taps a button, the view picks that up. Sometimes it handles the interaction directly, but more often it passes the user&#8217;s intent along to the view model. The view just says &#8220;the user tapped this&#8221; and moves on.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">The View Model</h3>



<p class="wp-block-paragraph">The view model is the UI backing layer. Because SwiftUI is state-driven, everything revolves around UI state, and managing that state can get complex. When do you change it? What moves you from loading to success, or to an error? What happens when a network request comes back? All of this needs to live somewhere, and that somewhere is the view model.</p>



<p class="wp-block-paragraph">The view model owns the UI state and decides when and how it changes. It can also prepare data for display, like formatting a price, and act as a gatekeeper for what&#8217;s allowed to happen.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">The Model</h3>



<p class="wp-block-paragraph">The model is your data: the structures your app works with, and the logic that naturally belongs to that data, like validation rules.</p>



<p class="wp-block-paragraph">You&#8217;ll find that different developers mean different things by &#8220;model.&#8221; Some put their networking code here too. That&#8217;s a valid interpretation, but to keep things clear, I keep the model narrow: the data itself, plus the logic intrinsic to it.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What Defines a View Model: UI State + Mutations</h2>



<p class="wp-block-paragraph">You can characterize any view model by two things: what state it holds, and how it can change that state. That&#8217;s really all that defines it.</p>



<ul class="wp-block-list">
<li><strong>UI state:</strong> the data your views need to render. The items in a list, whether something is loading, an error message, which item is selected, whether a button is allowed to be tapped.</li>



<li><strong>Methods to change UI state:</strong> controlled mutations of that data. Adding an item, logging in, deleting a theme.</li>
</ul>



<p class="wp-block-paragraph">Because the view model backs the UI, its state has to be connected to the views that display it. That comes with two technical requirements:</p>



<ul class="wp-block-list">
<li><strong><code>@Observable</code></strong>: SwiftUI needs to track the state so it can update the views whenever it changes. Without observation, the view has no way of knowing that something happened.</li>



<li><strong><code>@MainActor</code></strong>: the UI reads this state, and UI work happens on the main actor. So the state, and the code that changes it, lives there too.</li>
</ul>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class CartViewModel {
    private(set) var items: &#91;CartItem&#93; = []   // UI state

    func addItem(id: String) { /* ... */ }    // mutation
    func removeItem(id: String) { /* ... */ } // mutation
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CartViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> items: &#91;CartItem&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []   </span><span style="color: #7B7F8B">// UI state</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">addItem</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">id</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }    </span><span style="color: #7B7F8B">// mutation</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">removeItem</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">id</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> } </span><span style="color: #7B7F8B">// mutation</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">There&#8217;s one more rule I find really helpful: <strong>a view model communicates through state, never through UI instructions.</strong> Say a user isn&#8217;t allowed to buy a certain product. The view model doesn&#8217;t say &#8220;disable the button.&#8221; It says &#8220;this action is not allowed.&#8221; The view is the one that translates that into a disabled button.</p>



<p class="wp-block-paragraph">In the same way, a view model can prepare data for display, like turning a raw price into a formatted string. But it never decides how that information is rendered. It knows <em>what</em> to show, never <em>how</em> to show it. That&#8217;s view territory.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why Put State and Mutations Together?</h2>



<p class="wp-block-paragraph">I like to use the law of proximity here: <strong>things that change together should live together.</strong></p>



<p class="wp-block-paragraph">You can look at UI state from two directions. When the state changes, the UI changes, so the state should live close to the views that use it. That&#8217;s true. But the state and the code that <em>changes</em> it belong together just as tightly.</p>



<p class="wp-block-paragraph">Think about a SwiftUI view hierarchy where several views use the same state, and each of those subviews can mutate it. The state declaration moves higher up the view tree, but the code that changes it is scattered across different subviews. Things get messy quickly. If you want to know where a value was changed, you have to go hunting through the hierarchy.</p>



<p class="wp-block-paragraph">A view model brings them back together. The state lives in one place, and the methods that change it live right next to it. You get a single source of truth for the state, and one place that&#8217;s responsible for changing it properly and consistently.</p>



<p class="wp-block-paragraph">That gives you a few concrete benefits:</p>



<ul class="wp-block-list">
<li><strong>Consistent mutations:</strong> every change goes through the same methods, so it happens the same way every time.</li>



<li><strong>No duplicated logic:</strong> if several views need to make the same change, they call the same method. Duplicated logic tends to drift. Sooner or later you update one copy and forget the other, and your state goes out of sync.</li>



<li><strong>Easier to follow:</strong> when you want to know how the state changes, you look in one place.</li>
</ul>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>


<div class="gb-container gb-container-c5dedc2e">
<div class="gb-container gb-container-f7a433c7">
<div class="gb-container gb-container-7a8dc7a3">

<figure class="gb-block-image gb-block-image-f04fbbad"><a href="https://www.swiftyplace.com/free-swiftui-layout-cookbook" target="_blank" rel="noopener noreferrer"><img loading="lazy" decoding="async" width="1446" height="1552" class="gb-image gb-image-f04fbbad" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp" alt="" title="lead magnet mvvm" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp 1446w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-280x300.webp 280w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-954x1024.webp 954w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-768x824.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-1431x1536.webp 1431w" sizes="auto, (max-width: 1446px) 100vw, 1446px" /></a></figure>

</div>

<div class="gb-container gb-container-98352fe6">

<h4 class="gb-headline gb-headline-4aacd94c gb-headline-text">Get the MVVM SwiftUI Cheat Sheet</h4>



<p class="gb-headline gb-headline-5e6c4f85 gb-headline-text">The cheat sheet puts the whole system on a few pages: what goes in a view model, which view owns it, how to pass it down the tree, common mistakes, and code skeletons you can copy.</p>


<div class="gb-container gb-container-34a8ad02">

<a class="gb-button gb-button-63e90e76 gb-button-blue" href="https://school.swiftyplace.com/f/swiftui-mvvm-cheat-sheet" target="_blank" rel="noopener noreferrer"><span class="gb-button-text">Get the free cheat sheet</span><span class="gb-icon"><svg aria-hidden="true" role="img" height="1em" width="1em" viewBox="0 0 256 512" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"></path></svg></span></a>

</div>
</div>
</div>
</div>


<div style="height:46px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Example: The Shopping Cart</h2>



<p class="wp-block-paragraph">Let&#8217;s see how these layers play out with a shopping cart.</p>



<p class="wp-block-paragraph"></p>


<div class="gb-container gb-container-08f18f86">

<figure class="gb-block-image gb-block-image-ad983c4f"><img loading="lazy" decoding="async" width="1158" height="1152" class="gb-image gb-image-ad983c4f" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/Preseentation-Layer-Backing-example.jpg" alt="" title="Preseentation Layer - Backing - example" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/Preseentation-Layer-Backing-example.jpg 1158w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Preseentation-Layer-Backing-example-300x298.jpg 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Preseentation-Layer-Backing-example-1024x1019.jpg 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Preseentation-Layer-Backing-example-150x150.jpg 150w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Preseentation-Layer-Backing-example-768x764.jpg 768w" sizes="auto, (max-width: 1158px) 100vw, 1158px" /></figure>

</div>


<div style="height:31px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">The Model</h3>



<p class="wp-block-paragraph">The model is the raw data: a cart item with a product ID and a quantity, and the product information itself.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct CartItem {
    let id: String
    var quantity: Int
}

struct Product {
    let id: String
    let name: String
    let price: Double
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">CartItem</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> quantity: </span><span style="color: #97E1F1; font-style: italic">Int</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Product</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> name: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> price: </span><span style="color: #97E1F1; font-style: italic">Double</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This isn&#8217;t what the UI wants to show, though. The view needs a name, a quantity, and nicely formatted prices, all in one place. So we add a display type that has exactly what the view needs:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct CartDisplayItem {
    let id: String
    let name: String
    let quantity: Int
    let formattedPrice: String
    let formattedSubtotal: String
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">CartDisplayItem</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> name: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> quantity: </span><span style="color: #97E1F1; font-style: italic">Int</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> formattedPrice: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> formattedSubtotal: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">The View Model</h3>



<p class="wp-block-paragraph">The view model owns the state of what&#8217;s in the cart. It turns the raw data into display items, calculates the total, and acts as a gatekeeper: if the view wants to change the cart, it has to call <code>addItem</code> or <code>removeItem</code>. It can&#8217;t reach in and change the items directly, which is what <code>private(set)</code> makes sure of.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class CartViewModel {
    private(set) var items: &#91;CartItem&#93; = []
    private let products: &#91;String: Product&#93;

    init(products: &#91;String: Product&#93;) {
        self.products = products
    }

    // Converts model data into what the view displays
    var displayItems: &#91;CartDisplayItem&#93; {
        items.compactMap { item in
            guard let product = products&#91;item.id&#93; else { return nil }
            let subtotal = product.price * Double(item.quantity)
            return CartDisplayItem(
                id: item.id,
                name: product.name,
                quantity: item.quantity,
                formattedPrice: formatPrice(product.price),
                formattedSubtotal: formatPrice(subtotal)
            )
        }
    }

    var formattedTotal: String {
        let total = items.reduce(0.0) { sum, item in
            guard let product = products&#91;item.id&#93; else { return sum }
            return sum + product.price * Double(item.quantity)
        }
        return formatPrice(total)
    }

    // Gatekeeper: the view tells us what it wants, we decide if it's allowed
    func canAddItem(id: String) -> Bool {
        true // e.g. check stock or purchase limits
    }

    func addItem(id: String) {
        if let index = items.firstIndex(where: { $0.id == id }) {
            items&#91;index&#93;.quantity += 1
        } else {
            items.append(CartItem(id: id, quantity: 1))
        }
    }

    func removeItem(id: String) {
        items.removeAll { $0.id == id }
    }

    private func formatPrice(_ price: Double) -> String {
        price.formatted(.currency(code: "USD"))
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CartViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> items: &#91;CartItem&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> products: &#91;</span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> Product&#93;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">products</span><span style="color: #F6F6F4">: &#91;</span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> Product&#93;) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.products </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> products</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// Converts model data into what the view displays</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> displayItems: &#91;CartDisplayItem&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        items.</span><span style="color: #97E1F1">compactMap</span><span style="color: #F6F6F4"> { item </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">guard</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> product </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> products&#91;item.id&#93; </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> subtotal </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> product.price </span><span style="color: #F286C4">*</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Double</span><span style="color: #F6F6F4">(item.quantity)</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CartDisplayItem</span><span style="color: #F6F6F4">(</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: item.id,</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">name</span><span style="color: #F6F6F4">: product.name,</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">quantity</span><span style="color: #F6F6F4">: item.quantity,</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">formattedPrice</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">formatPrice</span><span style="color: #F6F6F4">(product.price),</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">formattedSubtotal</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">formatPrice</span><span style="color: #F6F6F4">(subtotal)</span></span>
<span class="line"><span style="color: #F6F6F4">            )</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> formattedTotal: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> total </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> items.</span><span style="color: #97E1F1">reduce</span><span style="color: #F6F6F4">(</span><span style="color: #BF9EEE">0.0</span><span style="color: #F6F6F4">) { sum, item </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">guard</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> product </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> products&#91;item.id&#93; </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> sum }</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> sum </span><span style="color: #F286C4">+</span><span style="color: #F6F6F4"> product.price </span><span style="color: #F286C4">*</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Double</span><span style="color: #F6F6F4">(item.quantity)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">formatPrice</span><span style="color: #F6F6F4">(total)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// Gatekeeper: the view tells us what it wants, we decide if it&#39;s allowed</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">canAddItem</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">id</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Bool</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE">true</span><span style="color: #F6F6F4"> </span><span style="color: #7B7F8B">// e.g. check stock or purchase limits</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">addItem</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">id</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> index </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> items.</span><span style="color: #97E1F1">firstIndex</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">where</span><span style="color: #F6F6F4">: { </span><span style="color: #BF9EEE; font-style: italic">$0</span><span style="color: #F6F6F4">.id </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> id }) {</span></span>
<span class="line"><span style="color: #F6F6F4">            items&#91;index&#93;.quantity </span><span style="color: #F286C4">+=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">1</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            items.</span><span style="color: #97E1F1">append</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">CartItem</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: id, </span><span style="color: #97E1F1">quantity</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE">1</span><span style="color: #F6F6F4">))</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">removeItem</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">id</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">        items.</span><span style="color: #97E1F1">removeAll</span><span style="color: #F6F6F4"> { </span><span style="color: #BF9EEE; font-style: italic">$0</span><span style="color: #F6F6F4">.id </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> id }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">formatPrice</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">price</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Double</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        price.</span><span style="color: #97E1F1">formatted</span><span style="color: #F6F6F4">(.</span><span style="color: #97E1F1">currency</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">code</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">USD</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">))</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">The View</h3>



<p class="wp-block-paragraph">With all that logic in the view model, the view becomes very simple. No formatting, no calculations. Just a <code>ForEach</code> with some <code>Text</code> views, a font, and a <code>disabled</code> modifier.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct CartView: View {
     var viewModel: CartViewModel

    var body: some View {
        VStack {
            ForEach(viewModel.displayItems, id: .id) { item in
                HStack {
                    Text(item.name)
                    Text("×(item.quantity)")
                    Spacer()
                    Text(item.formattedSubtotal)
                }
            }

            Text("Total: (viewModel.formattedTotal)")
                .font(.headline)

            Button("Add iPhone") {
                viewModel.addItem(id: "iphone")
            }
            .disabled(!viewModel.canAddItem(id: "iphone"))
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">CartView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">     </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel: CartViewModel</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">VStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">ForEach</span><span style="color: #F6F6F4">(viewModel.displayItems, </span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: .id) { item </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #97E1F1">HStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(item.name)</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">×(item.quantity)</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">Spacer</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(item.formattedSubtotal)</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Total: (viewModel.formattedTotal)</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">                .</span><span style="color: #97E1F1">font</span><span style="color: #F6F6F4">(.headline)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Button</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Add iPhone</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">                viewModel.</span><span style="color: #97E1F1">addItem</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">iphone</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">disabled</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">!</span><span style="color: #F6F6F4">viewModel.</span><span style="color: #97E1F1">canAddItem</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">iphone</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">))</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Notice how <code>canAddItem</code> works. The view model only answers &#8220;is this allowed?&#8221; The view decides that &#8220;not allowed&#8221; means a disabled button.</p>



<p class="wp-block-paragraph">Now imagine all of this living inside the view: the price formatting, the subtotal and total calculations, the rules for adding and removing items. The view would get big and messy fast, and every other view that touches the cart would need its own copy of that logic.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">&#8220;One View Model per Screen&#8221;: A Rule That Trips You Up</h2>



<p class="wp-block-paragraph">You&#8217;ll often hear that MVVM comes with a rule: one view model per screen, or even one per view. I understand why people like it. It&#8217;s simple, it&#8217;s easy to explain, and every screen gets its own neat little backing class.</p>



<p class="wp-block-paragraph">But I think it&#8217;s a rule that breaks your app sooner or later, because it goes directly against the single source of truth.</p>



<p class="wp-block-paragraph">Think about the shopping cart. The cart screen shows the items. But the product list also needs the cart: the user adds items from there, and maybe the tab bar shows a badge with the number of items. If every screen gets its own view model, you now have two view models holding their own copy of the cart. Add an item on the product list, and the cart screen doesn&#8217;t know about it. So you start syncing them: notifications, callbacks, reloading on appear. That&#8217;s exactly the messy state we wanted to avoid. You also end up writing the same <code>addItem</code> logic twice, and those two versions will drift apart.</p>



<p class="wp-block-paragraph">There&#8217;s a second problem: lifetime. A view model created by a screen lives only as long as that screen. Navigate away and back, and the state is gone and has to be loaded again, even if it should have survived.</p>



<p class="wp-block-paragraph">The issue is that the rule scopes view models by screens. But screens are a UI concern. What a view model actually manages is state, so that&#8217;s what should decide its scope.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">A Practical Approach: Scope by State, Not by Screen</h3>



<p class="wp-block-paragraph">Here are the guidelines I use instead:</p>



<ol class="wp-block-list">
<li><strong>Start from the state, not the screen.</strong> Ask: what state is this, and which views need it?</li>



<li><strong>Every piece of UI state has exactly one owner.</strong> One view model owns the cart. Not one per screen that shows the cart.</li>



<li><strong>Place the owner where the state needs to live.</strong> High enough in the view tree that every view that needs the state can reach it, and long-lived enough that the state survives as long as it should. If only one screen needs it, keep it there.</li>



<li><strong>Share the instance, don&#8217;t copy the state.</strong> Views that need the same state get the same view model passed in, and they all call the same methods to change it.</li>
</ol>



<p class="wp-block-paragraph">In the example above, <code>CartView</code> is short lived because its inside a sheet. It gets the ViewModel passed in from its parent. The product list needs it too. The ownership moves up: the app owns one <code>CartViewModel</code> and hands it to every view that needs it:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ContentView: View {
    @State private var cartViewModel = CartViewModel()
    @State private var cartIsShown = false
    
    var body: some View {
        ProductListView(cartViewModel, cartViewModel)
           .sheet(isShown: cartIsShown) {
               CartView(cartViewModel: cartViewModel)
            }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ContentView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> cartViewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CartViewModel</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> cartIsShown </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"><span style="color: #F6F6F4">    </span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(cartViewModel, cartViewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">           .</span><span style="color: #97E1F1">sheet</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">isShown</span><span style="color: #F6F6F4">: cartIsShown) {</span></span>
<span class="line"><span style="color: #F6F6F4">               </span><span style="color: #97E1F1">CartView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">cartViewModel</span><span style="color: #F6F6F4">: cartViewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The product list adds an item, the badge updates, and the cart screen shows it, because they&#8217;re all looking at the same state, changed through the same methods.</p>



<p class="wp-block-paragraph">Notice that &#8220;one view model per screen&#8221; can still happen with this approach. If some state really belongs to only one screen, its view model naturally ends up there. The difference is that it&#8217;s a result of where the state belongs, not a rule you apply up front.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Drawing the Line: UI State vs. Background Work</h2>



<p class="wp-block-paragraph">In the cart example, everything happened locally. In a real app, adding an item to a cart probably means asking a server whether the item is still available. So where does that network code go? Into the view model?</p>



<p class="wp-block-paragraph">This is where one question helps you decide which layer code belongs to: <strong>does it own UI state?</strong></p>



<ul class="wp-block-list">
<li><strong>Yes → it&#8217;s a view model</strong> (or a store, whatever you want to call it). It&#8217;s <code>@Observable</code>, it runs on <code>@MainActor</code>, and views are connected to it.</li>



<li><strong>No → it belongs in a supporting layer behind the view model</strong>, like a service. It&#8217;s not observable, it&#8217;s not on the main actor, and it&#8217;s free to do its work in the background, for example with <code>@concurrent</code> functions.</li>
</ul>



<p class="wp-block-paragraph">This isn&#8217;t just a naming preference. It&#8217;s the reason I draw the line exactly here. Anything the UI needs to observe has to be observable and has to live on the main actor, because that&#8217;s where SwiftUI reads it. Anything that doesn&#8217;t touch UI state shouldn&#8217;t be there. Networking, decoding JSON, reading and writing files: that&#8217;s heavy work, and we want it off the main actor so the UI stays responsive.</p>


<div class="gb-container gb-container-979c67c2">

<figure class="gb-block-image gb-block-image-4cd9fbca"><img loading="lazy" decoding="async" width="1863" height="1056" class="gb-image gb-image-4cd9fbca" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing.webp" alt="" title="Presentation Layer - Backing" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing.webp 1863w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing-300x170.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing-1024x580.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing-768x435.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Presentation-Layer-Backing-1536x871.webp 1536w" sizes="auto, (max-width: 1863px) 100vw, 1863px" /></figure>

</div>


<p class="wp-block-paragraph">The user taps a button. The view passes that intent to the view model. The view model asks the service to do the work in the background. The data comes back, the view model updates its UI state on the main actor, and SwiftUI updates the view.</p>



<p class="wp-block-paragraph">In code, the service is a small helper with no state of its own:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct CartService {
    @concurrent
    func addItem(id: String) async throws -> &#91;CartItem&#93; {
        // Send the request, decode the response,
        // and return the cart as the server sees it
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">CartService</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@concurrent</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">addItem</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">id</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;CartItem&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #7B7F8B">// Send the request, decode the response,</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #7B7F8B">// and return the cart as the server sees it</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">And the view model uses it, but stays in charge of the state:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class CartViewModel {
    private(set) var items: &#91;CartItem&#93; = []
    private(set) var errorMessage: String?
    private let service = CartService()

    func addItem(id: String) async {
        do {
            items = try await service.addItem(id: id)
        } catch {
            errorMessage = "Couldn't add the item. Please try again."
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CartViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> items: &#91;CartItem&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">set</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> errorMessage: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> service </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CartService</span><span style="color: #F6F6F4">()</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">addItem</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">id</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">do</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            items </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> service.</span><span style="color: #97E1F1">addItem</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: id)</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">catch</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            errorMessage </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Couldn&#39;t add the item. Please try again.</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The service doesn&#8217;t know anything about the UI. The view model doesn&#8217;t know how the network request works. Each one has a clear job, and the boundary between them is exactly the boundary between the main actor and the background. This is good use of Separation of Concern principle (SoC).</p>



<p class="wp-block-paragraph">This also explains why naming matters. If something owns UI state, call it a view model or a store, even if someone labeled it a &#8220;service.&#8221; I see this a lot in code on GitHub and in blog posts: a &#8220;service&#8221; that holds UI state in a project that supposedly has no view models. It&#8217;s basically one, and the wrong name hides which code runs where and what the UI connects to.</p>



<p class="wp-block-paragraph">There&#8217;s more to say about this supporting layer, including the difference between services and repositories. I&#8217;ll cover that in a separate post.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">A View Model Is Not &#8220;All Business Logic&#8221;</h2>



<p class="wp-block-paragraph">You&#8217;ll often read that the view model is where your &#8220;business logic&#8221; goes. The problem is that &#8220;business logic&#8221; is a bag of everything. It doesn&#8217;t actually help you decide where a piece of code belongs.</p>



<p class="wp-block-paragraph">If you split it up, it becomes much clearer. Some business logic is about state: deciding what the UI should show and formatting data so it&#8217;s easy to display. That belongs in the view model. But a lot of business logic is about data access: network requests, file I/O, encoding and decoding. That belongs behind the view model, in the supporting layer.</p>



<p class="wp-block-paragraph">So instead of &#8220;the view model holds the business logic,&#8221; I&#8217;d put it this way: the view model holds the UI state and the logic that changes it. Everything else gets offloaded. This can lead to the dreaded &#8220;God&#8221; View Model, another mess we want to avoid.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">A Tool, Not a Rule</h2>



<p class="wp-block-paragraph">MVVM doesn&#8217;t mean you have to use a view model for every single view. In one of the original blog posts, Cooper and Peters made a point of saying they didn&#8217;t see it as a purist thing you apply everywhere. <strong>They saw it as a pattern that comes up naturally during development, something you reach for when the situation calls for it.</strong></p>



<p class="wp-block-paragraph">You always have the choice to keep all your code in the view. It&#8217;s your codebase. But when your views start piling up logic and state mutations, a view model is a very good tool to have.</p>



<p class="wp-block-paragraph">This brings us back to the argument that &#8220;the view is already the view model.&#8221; If you compare SwiftUI with WPF, where MVVM started, there are a lot of similarities: declarative UI, data bindings, reactive updates. And the goals are the same: keep your code organized, keep your views simple, and have clear rules for separating code into layers.</p>



<p class="wp-block-paragraph">So the argument is a bit strange when you think about it. Nobody ever said you <em>need</em> a view model. The real question is when one helps you, and in which situations.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Wrapping Up</h2>



<p class="wp-block-paragraph">Let&#8217;s bring it all together. A view model is a UI backing layer, and it&#8217;s defined by two things:</p>



<ul class="wp-block-list">
<li><strong>A single source of truth for UI state:</strong> the data your views need to render.</li>



<li><strong>A single source of state mutations:</strong> the methods responsible for changing that state properly and consistently.</li>
</ul>



<p class="wp-block-paragraph">Because it backs the UI, it&#8217;s <code>@Observable</code> and runs on <code>@MainActor</code>. Anything that doesn&#8217;t own UI state, like networking or file access, belongs in a supporting layer behind it, where it can do its work in the background.</p>



<p class="wp-block-paragraph">But a view model is also an extra layer, and every layer has a cost. In the next post, When to Use a View Model (and When It&#8217;s Overkill), we&#8217;ll look at concrete examples of when that cost is worth paying, and when a view-only solution is the better choice.</p>


<div class="gb-container gb-container-c5dedc2e">
<div class="gb-container gb-container-f7a433c7">
<div class="gb-container gb-container-7a8dc7a3">

<figure class="gb-block-image gb-block-image-f04fbbad"><a href="https://www.swiftyplace.com/free-swiftui-layout-cookbook" target="_blank" rel="noopener noreferrer"><img loading="lazy" decoding="async" width="1446" height="1552" class="gb-image gb-image-f04fbbad" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp" alt="" title="lead magnet mvvm" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm.webp 1446w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-280x300.webp 280w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-954x1024.webp 954w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-768x824.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/lead-magnet-mvvm-1431x1536.webp 1431w" sizes="auto, (max-width: 1446px) 100vw, 1446px" /></a></figure>

</div>

<div class="gb-container gb-container-98352fe6">

<h4 class="gb-headline gb-headline-4aacd94c gb-headline-text">Get the MVVM SwiftUI Cheat Sheet</h4>



<p class="gb-headline gb-headline-5e6c4f85 gb-headline-text">The cheat sheet puts the whole system on a few pages: what goes in a view model, which view owns it, how to pass it down the tree, common mistakes, and code skeletons you can copy.</p>


<div class="gb-container gb-container-34a8ad02">

<a class="gb-button gb-button-63e90e76 gb-button-blue" href="https://school.swiftyplace.com/f/swiftui-mvvm-cheat-sheet" target="_blank" rel="noopener noreferrer"><span class="gb-button-text">Get the free cheat sheet</span><span class="gb-icon"><svg aria-hidden="true" role="img" height="1em" width="1em" viewBox="0 0 256 512" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"></path></svg></span></a>

</div>
</div>
</div>
</div><p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/swiftui-mvvm">What Is a View Model in SwiftUI MVVM, Really</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/swiftui-mvvm/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>How I Architect SwiftUI Apps for Swift Concurrency &#8211; So the Compiler Stays Quiet</title>
		<link>https://www.swiftyplace.com/blog/swiftui-architecture-swift-concurrency?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=swiftui-architecture-swift-concurrency</link>
					<comments>https://www.swiftyplace.com/blog/swiftui-architecture-swift-concurrency#comments</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Sat, 10 Oct 2026 12:16:08 +0000</pubDate>
				<category><![CDATA[Swift Concurrency]]></category>
		<category><![CDATA[SwiftUI Architecture]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005711</guid>

					<description><![CDATA[<p>How I structure SwiftUI apps with Swift concurrency: where to use @MainActor, actors, and @concurrent so Sendable errors stop showing up.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/swiftui-architecture-swift-concurrency">How I Architect SwiftUI Apps for Swift Concurrency &#8211; So the Compiler Stays Quiet</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Structuring a SwiftUI app has always been about the same goals for me: well-organized code, components that each do one job, and an app that stays maintainable as it grows. I want to find things quickly, change a feature without breaking something else, and use previews without depending on a server.</p>



<p class="wp-block-paragraph">Swift concurrency added a new problem on top of that, and it&#8217;s painful. As soon as you add networking, caching, or authentication, the compiler starts complaining. Sendable errors, main actor isolation errors, data race warnings. You add <code>@MainActor</code> somewhere, and a new error shows up somewhere else. At some point you start wondering: does Swift concurrency even work with SwiftUI? And if it does, how are you supposed to structure your app so it fits?</p>



<p class="wp-block-paragraph">It does work. In this post, I&#8217;ll show you how I structure my SwiftUI apps so that they stay organized and maintainable, and so that SwiftUI and Swift concurrency work together instead of against each other.</p>



<figure class="gb-block-image gb-block-image-79d04854"><img loading="lazy" decoding="async" width="1437" height="674" class="gb-image gb-image-79d04854" src="http://www.swiftyplace.com/wp-content/uploads/2026/09/UI-Backing-Supporting-Layer-copy.png" alt="Layer architecture in SwiftUI with Swift Concurrency" title="UI &amp; Backing &amp; Supporting Layer copy" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/09/UI-Backing-Supporting-Layer-copy.png 1437w, https://www.swiftyplace.com/wp-content/uploads/2026/09/UI-Backing-Supporting-Layer-copy-300x141.png 300w, https://www.swiftyplace.com/wp-content/uploads/2026/09/UI-Backing-Supporting-Layer-copy-1024x480.png 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/09/UI-Backing-Supporting-Layer-copy-768x360.png 768w" sizes="auto, (max-width: 1437px) 100vw, 1437px" /></figure>



<div style="height:32px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Concurrency Is Part of Your Architecture</h2>



<p class="wp-block-paragraph">Swift concurrency is built into the type system. Isolation is part of a type, just like its properties and functions. That means every time you decide what goes into a type, you&#8217;re also making a concurrency decision, whether you realize it or not.</p>



<p class="wp-block-paragraph">What decides the isolation a type needs is the state it holds. So when I separate my code into components, I look at their state. There are three kinds I care about: UI state, non-UI state, and no state at all. Each one leads to a different kind of component.</p>



<div style="height:32px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">My View Model is for UI State, and Methods to change UI State</h2>



<p class="wp-block-paragraph">UI state is anything your views read to render themselves: the list of products, a loading flag, an error message. Apple gave us <code>@Observable</code> for exactly this. It tracks which properties a view reads and updates the view when they change.</p>



<p class="wp-block-paragraph">Because SwiftUI renders on the main actor, that state belongs on the main actor too:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class ProductListViewModel {
    var products: &#91;Product&#93; = []
    var isLoading = false
    var errorMessage: String?

    private let service: ProductService

    init(service: ProductService) {
        self.service = service
    }

    func loadProducts() async {
        isLoading = true
        defer { isLoading = false }

        do {
            products = try await service.fetchProducts()
        } catch {
            errorMessage = "Could not load products."
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductListViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> products: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> errorMessage: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> service: ProductService</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">service</span><span style="color: #F6F6F4">: ProductService) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.service </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> service</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">true</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">defer</span><span style="color: #F6F6F4"> { isLoading </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">false</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">do</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            products </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> service.</span><span style="color: #97E1F1">fetchProducts</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        } </span><span style="color: #F286C4">catch</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            errorMessage </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Could not load products.</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Thinking about the view model this way made me rethink what I put into it. My view models used to collect everything: caches, tokens, helper state, anything that didn&#8217;t fit elsewhere. Now I follow a simple rule: <strong>the view model owns and mutates UI state</strong>. Functions that are called fromt the UI e.g. button press, call the view model methods that are @MainActor.</p>



<p class="wp-block-paragraph">That keeps my view models small, and focused on a clear Separation of Concern.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">My Services Are Stateless</h2>



<p class="wp-block-paragraph">A service fetches data, decodes it, maybe transforms it, and returns it. It doesn&#8217;t need to remember anything. And when <strong>a type has no state</strong>, there&#8217;s nothing to protect from data races.</p>



<p class="wp-block-paragraph">That makes services the easiest layer to get right. A service is just a struct:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct RemoteProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> &#91;Product&#93; {
        let url = URL(string: "https://api.example.com/products")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(&#91;Product&#93;.self, from: data)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">RemoteProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ProductService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@concurrent</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> url </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">URL</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">string</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">https://api.example.com/products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span><span style="color: #F286C4">!</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> (data, </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> URLSession.shared.</span><span style="color: #97E1F1">data</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: url)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">JSONDecoder</span><span style="color: #F6F6F4">().</span><span style="color: #97E1F1">decode</span><span style="color: #F6F6F4">(&#91;Product&#93;.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: data)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">It&#8217;s nonisolated because it doesn&#8217;t belong to any actor. This allows me to move it across actors. For the heavy work, like decoding a large response, I mark the function with <code>@concurrent</code> so it runs in the background instead of blocking the main actor.</p>



<p class="wp-block-paragraph">The view model calls it with <code>await</code>, the work happens off the main actor, and the result comes back to the view model, which assigns it to its UI state. One clear crossing point, and the compiler checks it for me.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Heavy Work Inside a View Model</h2>



<p class="wp-block-paragraph">Sometimes part of a view model function does heavy work. Filtering and sorting a large list, for example, or processing an image. The view model is on the main actor, so that work would block the UI.</p>



<p class="wp-block-paragraph">In that case, I pull the heavy part out into its own function and mark it <code>@concurrent</code>:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class ProductListViewModel {
    var allProducts: &#91;Product&#93; = []
    var filteredProducts: &#91;Product&#93; = []

    func search(for query: String) async {
        filteredProducts = await filter(allProducts, matching: query)
    }

    @concurrent
    private func filter(_ products: &#91;Product&#93;, matching query: String) async -> &#91;Product&#93; {
        products
            .filter { $0.name.localizedCaseInsensitiveContains(query) }
            .sorted { $0.name &lt; $1.name }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProductListViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> allProducts: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> filteredProducts: &#91;Product&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> []</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">search</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">query</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        filteredProducts </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">filter</span><span style="color: #F6F6F4">(allProducts, </span><span style="color: #97E1F1">matching</span><span style="color: #F6F6F4">: query)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@concurrent</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">filter</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">products</span><span style="color: #F6F6F4">: &#91;Product&#93;, </span><span style="color: #62E884">matching</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">query</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        products</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">filter</span><span style="color: #F6F6F4"> { </span><span style="color: #BF9EEE; font-style: italic">$0</span><span style="color: #F6F6F4">.name.</span><span style="color: #97E1F1">localizedCaseInsensitiveContains</span><span style="color: #F6F6F4">(query) }</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">sorted</span><span style="color: #F6F6F4"> { </span><span style="color: #BF9EEE; font-style: italic">$0</span><span style="color: #F6F6F4">.name </span><span style="color: #F286C4">&lt;</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE; font-style: italic">$1</span><span style="color: #F6F6F4">.name }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The <code>@concurrent</code> function runs in the background, so it can&#8217;t touch the view model&#8217;s state directly. Everything it needs comes in as parameters, and the result goes back as a return value. The main actor function passes the data in, awaits the result, and assigns it to the UI state.</p>



<p class="wp-block-paragraph">It&#8217;s the same pattern as with services: the state stays on the main actor, and only <code>Sendable</code> values cross over to the background and back.</p>



<p class="wp-block-paragraph">If you notice that you&#8217;re writing a lot of these functions in one view model, that&#8217;s usually a sign the work belongs in a service instead.</p>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Protocols and Sendable</h2>



<p class="wp-block-paragraph">Sendable errors are really annoying. They show up in places you don&#8217;t expect, and the messages don&#8217;t always tell you what&#8217;s actually wrong.</p>



<p class="wp-block-paragraph">Most of my Sendable errors came from services that were classes with some state hidden inside: a cached response, a counter, a mutable configuration. The moment such a service was stored in a main actor view model and called across isolation boundaries, the compiler complained. And it was right, because that state really could be accessed from two places at once.</p>



<p class="wp-block-paragraph">With a stateless struct, the problem disappears. A struct without mutable state is <code>Sendable</code> naturally, so I put the requirement right into the protocol:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated protocol ProductService: Sendable {
    func fetchProducts() async throws -> &#91;Product&#93;
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">protocol</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Every service I inject is now guaranteed to be safe to pass around. And the protocol gives me something I use all the time: I can swap the real service for a mock in previews and tests.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct MockProductService: ProductService {
    func fetchProducts() async throws -> &#91;Product&#93; {
        &#91;Product(id: UUID(), name: "Preview Product", price: 9.99)&#93;
    }
}

#Preview {
    ProductListView(viewModel: ProductListViewModel(service: MockProductService()))
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">MockProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ProductService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;Product&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        &#91;</span><span style="color: #97E1F1">Product</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">UUID</span><span style="color: #F6F6F4">(), </span><span style="color: #97E1F1">name</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Preview Product</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">price</span><span style="color: #F6F6F4">: </span><span style="color: #BF9EEE">9.99</span><span style="color: #F6F6F4">)&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">#Preview</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProductListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">ProductListViewModel</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">service</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">MockProductService</span><span style="color: #F6F6F4">()))</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">No server, no network, no Sendable errors. Just data in my preview.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Models: What Crosses From the Background to the Main Actor</h2>



<p class="wp-block-paragraph">Data travels from the service, which runs in the background, to the view model on the main actor. Whatever crosses that boundary has to be <code>Sendable</code>.</p>



<h3 class="wp-block-heading">The Simple Way: A Struct</h3>



<p class="wp-block-paragraph">In most cases, I just use a struct for my model:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct Product: Identifiable, Codable, Sendable {
    let id: String
    let name: String
    let price: Decimal
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Product</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Identifiable</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">Codable</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> name: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> price: Decimal</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The service decodes it in the background and returns it, and the view model assigns it to its UI state. A struct with immutable properties is <code>Sendable</code>, so it crosses the boundary without complaints.</p>



<p class="wp-block-paragraph">For many apps, this is all you need.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">When the UI Modifies the Data: DTOs</h3>



<p class="wp-block-paragraph">Sometimes a struct isn&#8217;t enough. The user can rename a product or mark it as a favorite, and the views should update right away. For that, you want an <code>@Observable</code> class:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
@Observable
final class Product: Identifiable {
    let id: String
    var name: String
    var isFavorite: Bool

    init(dto: ProductDTO) {
        id = dto.id
        name = dto.name
        isFavorite = dto.isFavorite
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">@Observable</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">Product</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Identifiable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> name: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> isFavorite: </span><span style="color: #97E1F1; font-style: italic">Bool</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">dto</span><span style="color: #F6F6F4">: ProductDTO) {</span></span>
<span class="line"><span style="color: #F6F6F4">        id </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> dto.id</span></span>
<span class="line"><span style="color: #F6F6F4">        name </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> dto.name</span></span>
<span class="line"><span style="color: #F6F6F4">        isFavorite </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> dto.isFavorite</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">This class is UI state, so it&#8217;s isolated to the main actor. That means the service can&#8217;t create it in the background. Instead, the service returns a DTO, a data transfer object, which is a simple <code>Sendable</code> struct:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct ProductDTO: Codable, Sendable {
    let id: String
    let name: String
    let isFavorite: Bool
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ProductDTO</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Codable</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> name: </span><span style="color: #97E1F1; font-style: italic">String</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> isFavorite: </span><span style="color: #97E1F1; font-style: italic">Bool</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>nonisolated struct RemoteProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> &#91;ProductDTO&#93; {
        let url = URL(string: "https://api.example.com/products")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(&#91;ProductDTO&#93;.self, from: data)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">nonisolated</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">RemoteProductService</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ProductService </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@concurrent</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">fetchProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> &#91;ProductDTO&#93; {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> url </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">URL</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">string</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">https://api.example.com/products</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span><span style="color: #F286C4">!</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> (data, </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> URLSession.shared.</span><span style="color: #97E1F1">data</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: url)</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">JSONDecoder</span><span style="color: #F6F6F4">().</span><span style="color: #97E1F1">decode</span><span style="color: #F6F6F4">(&#91;ProductDTO&#93;.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: data)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The DTO crosses the boundary, and the view model turns it into the observable model on the main actor:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>func loadProducts() async {
    do {
        let dtos = try await service.fetchProducts()
        products = dtos.map(Product.init)
    } catch {
        errorMessage = "Could not load products."
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadProducts</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">do</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> dtos </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> service.</span><span style="color: #97E1F1">fetchProducts</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        products </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> dtos.</span><span style="color: #97E1F1">map</span><span style="color: #F6F6F4">(Product.</span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">    } </span><span style="color: #F286C4">catch</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        errorMessage </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Could not load products.</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">So the work is split by isolation. Fetching and decoding happen in the background and produce Sendable DTOs. Creating the observable models happens on the main actor, where they&#8217;re used as UI state. When you save changes, it goes the other way: the view model creates a DTO from the model and hands it to the service.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">State the UI Never Sees Gets Its Own Actor</h2>



<p class="wp-block-paragraph">Some state isn&#8217;t UI state, but it&#8217;s still state. My favorite example is a token store. It holds the authentication token, refreshes it when it expires, and hands it to the network layer. No view ever displays the token.</p>



<p class="wp-block-paragraph">So it doesn&#8217;t belong in the view model, and it doesn&#8217;t belong on the main actor. But it&#8217;s shared mutable state: several requests might ask for the token at the same time, and two of them might try to refresh it at once. That&#8217;s what actors are for:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>actor TokenStore {
    private var token: String?
    private var refreshTask: Task&lt;String, Error>?

    func validToken() async throws -> String {
        if let token { return token }

        if let refreshTask {
            return try await refreshTask.value
        }

        let task = Task { try await refreshToken() }
        refreshTask = task
        defer { refreshTask = nil }

        let newToken = try await task.value
        token = newToken
        return newToken
    }

    private func refreshToken() async throws -> String {
        // Call your auth endpoint here
        "new-token"
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">actor</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">TokenStore</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> token: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> refreshTask: Task&lt;</span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">Error</span><span style="color: #F6F6F4">&gt;</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">validToken</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> token { </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> token }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> refreshTask {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> refreshTask.</span><span style="color: #BF9EEE">value</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> task </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">Task</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">refreshToken</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">        refreshTask </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> task</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">defer</span><span style="color: #F6F6F4"> { refreshTask </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> newToken </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> task.</span><span style="color: #BF9EEE">value</span></span>
<span class="line"><span style="color: #F6F6F4">        token </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> newToken</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> newToken</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">refreshToken</span><span style="color: #F6F6F4">() </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">throws</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #7B7F8B">// Call your auth endpoint here</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">new-token</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The actor protects its state. If several requests ask for a token while a refresh is running, they all wait for the same refresh instead of starting their own. That kind of bug is really hard to find at runtime, and here the structure prevents it.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">My Cheat Sheet</h2>



<p class="wp-block-paragraph">This is how it all comes together:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>State</th><th>Layer</th><th>Isolation</th></tr></thead><tbody><tr><td>UI state</td><td>View, view model, coordinator</td><td><code>@MainActor</code></td></tr><tr><td>Non-UI state</td><td>Token store, cache</td><td>Its own <code>actor</code></td></tr><tr><td>No state</td><td>Service</td><td>Nonisolated struct, <code>@concurrent</code> functions for heavy work</td></tr><tr><td>Data passed between layers</td><td>Model</td><td><code>Sendable</code> value types</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">When I create a new type, I check what state it holds. That tells me which layer it belongs to and how it&#8217;s isolated, so I don&#8217;t have to fix concurrency errors afterward.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">A Note on Project Settings</h2>



<p class="wp-block-paragraph">The code in this post assumes a new Xcode 26 project, where default main actor isolation is turned on. In that setup, every type is on the main actor unless you opt out, which is why the services, protocols, and models are marked <code>nonisolated</code>. The view model is main actor either way, but I keep the explicit <code>@MainActor</code> so the intent is clear.</p>



<p class="wp-block-paragraph">If your project doesn&#8217;t use default main actor isolation, it&#8217;s the other way around. You don&#8217;t need the <code>nonisolated</code> markers, but you do need to add <code>@MainActor</code> to your view models yourself. <code>@Observable</code> doesn&#8217;t do that for you.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">My Advice</h2>



<p class="wp-block-paragraph">If you&#8217;re fighting Swift concurrency right now, here&#8217;s what I&#8217;d recommend:</p>



<ul class="wp-block-list">
<li><strong>Before you create a type, look at its state.</strong> UI state, non-UI state, or no state. That tells you where it belongs.</li>



<li><strong>Keep your view models for UI state.</strong> If the UI doesn&#8217;t read it, move it out.</li>



<li><strong>Make services stateless whenever you can.</strong> A stateless struct is the easiest thing to make Sendable.</li>



<li><strong>Use protocols that require <code>Sendable</code>.</strong> You get safe injection and easy mocks for previews and tests.</li>



<li><strong>Make your models value types.</strong> They cross every boundary without complaints.</li>



<li><strong>Give shared non-UI state its own actor.</strong> Don&#8217;t hide it in a view model or a singleton.</li>



<li><strong>When you hit a Sendable error, check where your state lives before adding annotations.</strong> Most of the time, the error is pointing at your structure.</li>
</ul>



<p class="wp-block-paragraph">The compiler is showing you exactly where your state is in the wrong place. Once your app is structured around state, it mostly stays quiet.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"></p>



<h2 class="wp-block-heading">Further Reading</h2>



<p class="wp-block-paragraph"><em>If you want to go deeper into how to separate your SwiftUI code, from views and view models to services and modules, check out my course.</em></p>



<p class="wp-block-paragraph"><strong>Apple&#8217;s WWDC25 sessions</strong></p>



<ul class="wp-block-list">
<li><strong><a href="https://developer.apple.com/videos/play/wwdc2025/266" target="_blank" rel="noopener">Explore concurrency in SwiftUI</a>.</strong>&nbsp;A good starting point if you&#8217;re wondering how SwiftUI and Swift concurrency fit together. It shows how SwiftUI runs on the main actor by default and hands work off to other actors.</li>



<li><strong><a href="https://developer.apple.com/videos/play/wwdc2025/268" target="_blank" rel="noopener">Embracing Swift concurrency</a>.</strong>&nbsp;The best overview of the core concepts. It walks through taking an app from single-threaded to concurrent, with chapters on value types, actor-isolated types, and actors that match the layers in this post.</li>



<li><strong><a href="https://developer.apple.com/videos/play/wwdc2025/270" target="_blank" rel="noopener">Code-along: Elevate an app with Swift concurrency</a>.</strong>&nbsp;A hands-on session that starts with a main-actor app, moves work to the background, and works through fixing data-race safety errors. It also covers the approachable concurrency settings.</li>
</ul>



<p class="wp-block-paragraph"><strong>Swift documentation</strong></p>



<ul class="wp-block-list">
<li><strong><a href="https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/" target="_blank" rel="noopener">The Swift Programming Language: Concurrency</a>.</strong>&nbsp;The official language guide chapter on async/await, actors, and Sendable.</li>



<li><strong><a href="https://www.swift.org/migration/documentation/migrationguide/" target="_blank" rel="noopener">Swift 6 Migration Guide</a>.</strong>&nbsp;Useful if you&#8217;re moving an existing project to Swift 6 and hitting data-race errors.</li>
</ul>



<p class="wp-block-paragraph"><strong>Swift Evolution proposals</strong></p>



<p class="wp-block-paragraph">For the details behind the code in this post:</p>



<ul class="wp-block-list">
<li><strong><a href="https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md" target="_blank" rel="noopener">SE-0461: Run nonisolated async functions on the caller&#8217;s actor by default</a>.</strong>&nbsp;Introduces&nbsp;<code>@concurrent</code>&nbsp;and explains why it&#8217;s needed for background work.</li>



<li><strong><a href="https://github.com/swiftlang/swift-evolution/blob/main/proposals/0466-control-default-actor-isolation.md" target="_blank" rel="noopener">SE-0466: Control default actor isolation inference</a>.</strong>&nbsp;The proposal behind default main actor isolation in new Xcode projects.</li>



<li><strong><a href="https://github.com/swiftlang/swift-evolution/blob/main/proposals/0449-nonisolated-for-global-actor-cutoff.md" target="_blank" rel="noopener">SE-0449: Allow nonisolated to prevent global actor inference</a>.</strong>&nbsp;Explains why you can mark types and protocols as&nbsp;<code>nonisolated</code>.</li>
</ul>



<p class="wp-block-paragraph"></p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/swiftui-architecture-swift-concurrency">How I Architect SwiftUI Apps for Swift Concurrency &#8211; So the Compiler Stays Quiet</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/swiftui-architecture-swift-concurrency/feed</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>What Is Sendable in Swift? How Swift Concurrency Protects Your State</title>
		<link>https://www.swiftyplace.com/blog/sendable-swift-concurrency?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=sendable-swift-concurrency</link>
					<comments>https://www.swiftyplace.com/blog/sendable-swift-concurrency#respond</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Thu, 08 Oct 2026 11:26:03 +0000</pubDate>
				<category><![CDATA[Swift]]></category>
		<category><![CDATA[Swift Concurrency]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005731</guid>

					<description><![CDATA[<p>What is Sendable in Swift? A practical explanation of data races, isolation, and why structs, classes, and actors are Sendable or not, with real examples.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/sendable-swift-concurrency">What Is Sendable in Swift? How Swift Concurrency Protects Your State</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">If you&#8217;ve worked with Swift concurrency, you&#8217;ve probably run into Sendable errors. A lot of them. And if you&#8217;re like me, your first reaction was to wonder why the compiler suddenly cares so much about whether something is &#8220;Sendable,&#8221; and what that even means.</p>



<p class="wp-block-paragraph">To understand Sendable, you first need to understand the problem it solves. And that problem is one of the most painful kinds of bugs in app development: data races.</p>



<figure class="gb-block-image gb-block-image-93508376"><img loading="lazy" decoding="async" width="971" height="249" class="gb-image gb-image-93508376" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/sendable-error-2.webp" alt="Sendable error in Xcode Swift 6" title="sendable error-2" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/sendable-error-2.webp 971w, https://www.swiftyplace.com/wp-content/uploads/2026/10/sendable-error-2-300x77.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/sendable-error-2-768x197.webp 768w" sizes="auto, (max-width: 971px) 100vw, 971px" /></figure>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Crash You Can&#8217;t Reproduce</h2>



<p class="wp-block-paragraph">Let&#8217;s start with some code that many of us have written at some point. Here&#8217;s a simple cache for image data:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>final class ImageCache {
    private var images: &#91;URL: Data&#93; = &#91;:&#93;

    func image(for url: URL) -> Data? {
        images&#91;url&#93;
    }

    func store(_ data: Data, for url: URL) {
        images&#91;url&#93; = data
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ImageCache</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> images: &#91;URL</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> Data&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> &#91;</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4">&#93;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">image</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">url</span><span style="color: #F6F6F4">: URL) </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> Data</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        images&#91;url&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">store</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">data</span><span style="color: #F6F6F4">: Data, </span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">url</span><span style="color: #F6F6F4">: URL) {</span></span>
<span class="line"><span style="color: #F6F6F4">        images&#91;url&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> data</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Nothing special. Now say you show a list of products, and each row loads its image in the background. When a download finishes, it stores the data in the shared cache so you don&#8217;t have to download it again:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>func loadImages(for urls: &#91;URL&#93;, cache: ImageCache) async {
    await withTaskGroup(of: Void.self) { group in
        for url in urls {
            group.addTask {
                guard let (data, _) = try? await URLSession.shared.data(from: url) else { return }
                cache.store(data, for: url)
            }
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">loadImages</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">urls</span><span style="color: #F6F6F4">: &#91;URL&#93;, </span><span style="color: #62E884; font-style: italic">cache</span><span style="color: #F6F6F4">: ImageCache) </span><span style="color: #F286C4">async</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">withTaskGroup</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">of</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Void</span><span style="color: #F6F6F4">.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { group </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">for</span><span style="color: #F6F6F4"> url </span><span style="color: #F286C4">in</span><span style="color: #F6F6F4"> urls {</span></span>
<span class="line"><span style="color: #F6F6F4">            group.</span><span style="color: #97E1F1">addTask</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">guard</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> (data, </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try?</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> URLSession.shared.</span><span style="color: #97E1F1">data</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: url) </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">                cache.</span><span style="color: #97E1F1">store</span><span style="color: #F6F6F4">(data, </span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: url)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">You test it, and everything works. Images load, scrolling is smooth, the cache does its job. You ship it.</p>



<p class="wp-block-paragraph">Then the crash reports come in. Not many, maybe one in a thousand sessions. The stack traces point somewhere deep inside <code>Dictionary</code>. You try to reproduce it on your device, and you can&#8217;t. You scroll the list a hundred times, and nothing happens.</p>



<p class="wp-block-paragraph">This is a data race. And in Swift 5, the compiler didn&#8217;t say a word about it.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What a Data Race Is</h2>



<p class="wp-block-paragraph">A data race happens when two pieces of code access the same mutable state at the same time, and at least one of them writes to it.</p>



<p class="wp-block-paragraph">In the image cache, that&#8217;s exactly what happens. Two rows finish their downloads at the same moment, and both call <code>store</code> on the same cache. Both write to the same dictionary at once. If the dictionary is in the middle of growing its storage when the second write comes in, its internal memory ends up in a broken state, and the app crashes.</p>



<p class="wp-block-paragraph">You can make this visible with an even smaller example. Here&#8217;s a counter, incremented 1,000 times from concurrent tasks:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>final class Counter {
    var value = 0

    func increment() {
        value += 1
    }
}

let counter = Counter()

await withTaskGroup(of: Void.self) { group in
    for _ in 0..&lt;1000 {
        group.addTask { counter.increment() }
    }
}

print(counter.value)</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">Counter</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> value </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">increment</span><span style="color: #F6F6F4">() {</span></span>
<span class="line"><span style="color: #F6F6F4">        value </span><span style="color: #F286C4">+=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">1</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> counter </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">Counter</span><span style="color: #F6F6F4">()</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">withTaskGroup</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">of</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Void</span><span style="color: #F6F6F4">.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { group </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">for</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">in</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span><span style="color: #F286C4">..&lt;</span><span style="color: #BF9EEE">1000</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        group.</span><span style="color: #97E1F1">addTask</span><span style="color: #F6F6F4"> { counter.</span><span style="color: #97E1F1">increment</span><span style="color: #F6F6F4">() }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #97E1F1">print</span><span style="color: #F6F6F4">(counter.</span><span style="color: #BF9EEE">value</span><span style="color: #F6F6F4">)</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">You&#8217;d expect 1,000. But run it a few times, and you might get 973, then 988, then 1,000. The result changes every time. <code>value += 1</code> looks like one step, but it&#8217;s really three: read the value, add one, write it back. When two tasks read the same value at the same moment, both write back the same result, and one increment is lost.</p>



<figure class="gb-block-image gb-block-image-8aa316d4"><img loading="lazy" decoding="async" width="1536" height="1024" class="gb-image gb-image-8aa316d4" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/Chaos-vs.-Order_-Serialized-Access.webp" alt="swift concurrency with sendable erros in swift. preventing data races with actors." title="Chaos vs. Order_ Serialized Access" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/Chaos-vs.-Order_-Serialized-Access.webp 1536w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Chaos-vs.-Order_-Serialized-Access-300x200.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Chaos-vs.-Order_-Serialized-Access-1024x683.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/Chaos-vs.-Order_-Serialized-Access-768x512.webp 768w" sizes="auto, (max-width: 1536px) 100vw, 1536px" /></figure>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why Data Races Are So Painful</h2>



<p class="wp-block-paragraph">Data races are some of the hardest bugs to deal with, for several reasons.</p>



<p class="wp-block-paragraph"><strong>They&#8217;re random.</strong> Whether a race happens depends on timing: which task runs first, how busy the device is, how fast the network responds. That&#8217;s why the cache worked perfectly on your device and crashed for one user in a thousand.</p>



<p class="wp-block-paragraph"><strong>They hide.</strong> The crash often happens far from the actual cause. The stack trace points into <code>Dictionary</code>, but the real problem is that two tasks shared the cache without protection.</p>



<p class="wp-block-paragraph"><strong>They corrupt data silently.</strong> Not every race crashes. The counter didn&#8217;t crash. It just gave the wrong result. In a real app, that could be a wrong total in a shopping cart or a lost user setting, and you might never notice.</p>



<p class="wp-block-paragraph"><strong>Preventing them was all on you.</strong> Before Swift concurrency, you protected shared state with locks or serial dispatch queues. That works, but only if you remember it everywhere, every time. Forget one lock, and the bug is back.</p>



<p class="wp-block-paragraph"><strong>The tools only helped at runtime.</strong> Thread Sanitizer can detect data races, but only if the race actually happens while it&#8217;s running. If the timing doesn&#8217;t line up during your test, it finds nothing.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Swift Concurrency Moves This into the Compiler</h2>



<p class="wp-block-paragraph">This is the problem Swift concurrency was designed to solve. Instead of hoping you remember every lock, the compiler checks for data races before your app ever runs.</p>



<p class="wp-block-paragraph">To do that, Swift concurrency is built into the type system. Isolation is part of a type, just like its properties and functions. Every piece of code runs in an isolation domain, as described in the <a href="https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/" target="_blank" rel="noopener">concurrency chapter of The Swift Programming Language</a>:</p>



<ul class="wp-block-list">
<li><strong>The main actor</strong>, where your UI code runs.</li>



<li><strong>A specific actor</strong>, which protects its own state.</li>



<li><strong>Nonisolated</strong>, which doesn&#8217;t belong to any actor.</li>
</ul>



<p class="wp-block-paragraph">Inside one isolation domain, only one piece of code runs at a time, so accessing state there is safe. The danger is when values cross from one domain to another, like when the cache is captured by several tasks running at the same time.</p>



<p class="wp-block-paragraph">And this is where the compiler steps in. In Swift 6, the <code>loadImages</code> function from the beginning doesn&#8217;t compile anymore. The compiler sees that the cache is shared between tasks that can run concurrently, and that it isn&#8217;t safe to share. It tells you that <code>ImageCache</code> isn&#8217;t Sendable.</p>


<div class="gb-container gb-container-">

<p class="wp-block-paragraph">The crash your users would have hit randomly in production is now a compiler error on your machine. This is what the <a href="https://www.swift.org/migration/documentation/migrationguide/" target="_blank" rel="noopener">Swift 6 migration guide</a> calls data race safety.</p>

</div>


<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What Sendable Means</h2>



<p class="wp-block-paragraph">Sendable is a promise that a value is safe to pass from one isolation domain to another. It was introduced in <a href="https://github.com/swiftlang/swift-evolution/blob/main/proposals/0302-concurrent-value-and-concurrent-closures.md" target="_blank" rel="noopener">SE-0302</a>, and you can find the full rules in <a href="https://developer.apple.com/documentation/swift/sendable" target="_blank" rel="noopener">Apple&#8217;s <code>Sendable</code> documentation</a>.</p>



<p class="wp-block-paragraph">When a type is Sendable, the compiler knows that sharing it across tasks or actors can&#8217;t cause a data race. When it isn&#8217;t, the compiler stops you from passing it across.</p>



<p class="wp-block-paragraph">A few things are worth knowing:</p>



<ul class="wp-block-list">
<li><strong>It&#8217;s checked at compile time.</strong> Sendable has no runtime cost. It only exists so the compiler can check your code.</li>



<li><strong>It&#8217;s only checked at boundaries.</strong> Within one isolation domain, non-Sendable types are completely fine. The compiler only cares when a value crosses from one domain to another.</li>



<li><strong>It&#8217;s about state.</strong> Whether a type can be Sendable depends on what state it holds and how that state is protected.</li>
</ul>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Which Types Are Sendable</h2>



<h3 class="wp-block-heading">Structs and Enums</h3>



<p class="wp-block-paragraph">Structs and enums are value types. When you pass one to another isolation domain, it&#8217;s copied, so both sides have their own copy and nothing is shared.</p>



<p class="wp-block-paragraph">That&#8217;s why a struct can have <code>var</code> properties and still be Sendable. Each copy has its own mutable state, and changing one doesn&#8217;t affect the other.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct Product: Sendable {
    let id: UUID
    var name: String    // fine, each copy has its own name
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Product</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> id: UUID</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> name: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// fine, each copy has its own name</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">A struct or enum is Sendable automatically if all its stored properties are Sendable. Standard types like <code>Int</code>, <code>String</code>, <code>Data</code>, <code>Array</code>, <code>Dictionary</code>, and <code>Optional</code> are Sendable as long as their contents are. One exception: public types don&#8217;t get this automatically, so you need to declare <code>Sendable</code> explicitly.</p>



<p class="wp-block-paragraph">But a struct isn&#8217;t automatically safe just because it&#8217;s a struct. If it holds a class that isn&#8217;t Sendable, the copy still points to the same shared instance:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct Order {
    var product: Product
    var cache: ImageCache   // a non-Sendable class, so Order isn't Sendable
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">Order</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> product: Product</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> cache: ImageCache   </span><span style="color: #7B7F8B">// a non-Sendable class, so Order isn&#39;t Sendable</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Classes Without <code>var</code></h3>



<p class="wp-block-paragraph">A class is a reference type. Passing it around doesn&#8217;t copy it. Everyone shares the same instance.</p>



<p class="wp-block-paragraph">But if nobody can change that instance, sharing it is safe. A class with only <code>let</code> properties of Sendable types can be Sendable. It has to be <code>final</code>, though, because a subclass could add mutable state. And you need to declare the conformance yourself:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>final class Configuration: Sendable {
    let baseURL: URL
    let timeout: TimeInterval

    init(baseURL: URL, timeout: TimeInterval) {
        self.baseURL = baseURL
        self.timeout = timeout
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">Configuration</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Sendable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> baseURL: URL</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> timeout: TimeInterval</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">baseURL</span><span style="color: #F6F6F4">: URL, </span><span style="color: #62E884; font-style: italic">timeout</span><span style="color: #F6F6F4">: TimeInterval) {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.baseURL </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> baseURL</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4">.timeout </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> timeout</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Classes With <code>var</code></h3>



<p class="wp-block-paragraph">This is the classic problem, and it&#8217;s exactly what our image cache and counter are. A class with mutable state, shared between isolation domains, means two pieces of code can read and write the same state at the same time.</p>



<p class="wp-block-paragraph">A class like this can&#8217;t simply be Sendable. It needs protection, and there are a few ways to add it.</p>



<p class="wp-block-paragraph"><strong>Isolate it to the main actor.</strong> A <code>@MainActor</code> class is Sendable, because all access to its state happens on the main actor, one at a time. This is what your view models look like:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>@MainActor
final class CounterViewModel {
    var count = 0   // protected by the main actor
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">@MainActor</span></span>
<span class="line"><span style="color: #F286C4">final</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">class</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">CounterViewModel</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> count </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span><span style="color: #F6F6F4">   </span><span style="color: #7B7F8B">// protected by the main actor</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph"><strong>Turn it into an actor.</strong> That&#8217;s coming up next.</p>



<p class="wp-block-paragraph"><strong>Protect it with a lock.</strong> You can synchronize the state yourself, for example with <code>Mutex</code> from the Synchronization framework. With older locks like <code>NSLock</code>, you mark the class <code>@unchecked Sendable</code>, which tells the compiler to trust you. More on that later.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Actors</h3>



<p class="wp-block-paragraph">Actors are always Sendable (WWDC session <a href="https://developer.apple.com/videos/play/wwdc2021/10133/" target="_blank" rel="noopener">Protect mutable state with Swift actors</a>.). They can have mutable state because they protect it themselves: only one caller accesses an actor&#8217;s state at a time. Everyone else waits for their turn, which is why you call an actor&#8217;s methods with <code>await</code> from outside.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>actor TokenStore {
    private var token: String?

    func store(_ newToken: String) {
        token = newToken
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">actor</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">TokenStore</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> token: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">store</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">newToken</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">) {</span></span>
<span class="line"><span style="color: #F6F6F4">        token </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> newToken</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">One thing to keep in mind: an actor protects its own state, not the values you pass into it. The parameters and return values of its methods still need to be Sendable.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Closures</h3>



<p class="wp-block-paragraph">Closures matter too, because tasks are closures. A closure that runs in another isolation domain needs to be <code>@Sendable</code>, which means it can only capture Sendable values and can&#8217;t capture mutable local variables.</p>



<p class="wp-block-paragraph">That&#8217;s exactly what went wrong in <code>loadImages</code>: the closure passed to <code>addTask</code> captured the non-Sendable cache. Many confusing errors with <code>Task</code> and callbacks come down to this rule.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h3 class="wp-block-heading">Summary</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Type</th><th>Sendable?</th><th>Why</th></tr></thead><tbody><tr><td>Struct or enum with Sendable properties</td><td>Yes, automatically</td><td>Copied when passed, nothing is shared</td></tr><tr><td>Struct with a non-Sendable class property</td><td>No</td><td>The copy still shares the class instance</td></tr><tr><td>Final class with only <code>let</code> properties</td><td>Yes, if declared</td><td>Shared, but nobody can change it</td></tr><tr><td>Class with <code>var</code> properties</td><td>No</td><td>Shared mutable state, so a data race risk</td></tr><tr><td><code>@MainActor</code> class</td><td>Yes</td><td>State is protected by the main actor</td></tr><tr><td>Actor</td><td>Yes, always</td><td>State is protected by the actor</td></tr><tr><td>Class with a lock, <code>@unchecked Sendable</code></td><td>Yes, on your word</td><td>You guarantee safety, the compiler doesn&#8217;t check</td></tr></tbody></table></figure>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Fixing the Image Cache</h2>



<p class="wp-block-paragraph">Back to the cache. Which option fits?</p>



<p class="wp-block-paragraph">The cache is shared mutable state. It needs to be shared, because every row should benefit from images that other rows already downloaded. And the UI doesn&#8217;t read the cache directly. So the right fit is an actor:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>actor ImageCache {
    private var images: &#91;URL: Data&#93; = &#91;:&#93;

    func image(for url: URL) -> Data? {
        images&#91;url&#93;
    }

    func store(_ data: Data, for url: URL) {
        images&#91;url&#93; = data
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">actor</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ImageCache</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> images: &#91;URL</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> Data&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> &#91;</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4">&#93;</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">image</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">url</span><span style="color: #F6F6F4">: URL) </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> Data</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        images&#91;url&#93;</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">store</span><span style="color: #F6F6F4">(</span><span style="color: #62E884">_</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">data</span><span style="color: #F6F6F4">: Data, </span><span style="color: #62E884">for</span><span style="color: #F6F6F4"> </span><span style="color: #FFB86C; font-style: italic">url</span><span style="color: #F6F6F4">: URL) {</span></span>
<span class="line"><span style="color: #F6F6F4">        images&#91;url&#93; </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> data</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">The only change in <code>loadImages</code> is an <code>await</code>:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>group.addTask {
    guard let (data, _) = try? await URLSession.shared.data(from: url) else { return }
    await cache.store(data, for: url)
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F6F6F4">group.</span><span style="color: #97E1F1">addTask</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">guard</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> (data, </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">try?</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> URLSession.shared.</span><span style="color: #97E1F1">data</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">from</span><span style="color: #F6F6F4">: url) </span><span style="color: #F286C4">else</span><span style="color: #F6F6F4"> { </span><span style="color: #F286C4">return</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> cache.</span><span style="color: #97E1F1">store</span><span style="color: #F6F6F4">(data, </span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: url)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Now only one task at a time can access the dictionary. When two downloads finish at the same moment, one waits for the other.</p>



<p class="wp-block-paragraph">Why not the other options? A struct would give every task its own copy, so it wouldn&#8217;t be a shared cache anymore. Putting the cache on the main actor would also be safe, but then every background task would have to hop over to the main actor just to store some data, competing with your UI for no reason.</p>



<p class="wp-block-paragraph">The crash is gone. And it&#8217;s gone not because you remembered a lock, but because the compiler won&#8217;t let you share the cache unsafely again.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Real Problem: Shared Mutable State Without Protection</h2>



<p class="wp-block-paragraph">If you take one thing from this post, let it be this: mutable state alone isn&#8217;t the problem. A struct can have <code>var</code> properties and be perfectly safe.</p>



<p class="wp-block-paragraph">The problem is state that is <strong>shared</strong> and <strong>mutable</strong> without <strong>protection</strong>. Take away any one of those three, and the data race is gone:</p>



<ul class="wp-block-list">
<li><strong>Value types</strong> avoid sharing, because every copy is separate.</li>



<li><strong>Immutable classes</strong> avoid mutation, because nobody can change them.</li>



<li><strong>Actors and the main actor</strong> add protection, because only one caller accesses the state at a time.</li>
</ul>



<p class="wp-block-paragraph">Sendable is how the compiler checks that one of these is true for every value crossing a boundary.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What This Means for Structuring Your App</h2>



<p class="wp-block-paragraph">Once you see Sendable this way, it changes how you structure your code. The question for every type becomes: what kind of state does it hold?</p>



<ul class="wp-block-list">
<li><strong>UI state</strong> lives on the main actor. That&#8217;s your view models and coordinators.</li>



<li><strong>Non-UI state</strong> lives in its own actor. That&#8217;s your image cache or a token store.</li>



<li><strong>No state</strong> means nothing to protect. That&#8217;s stateless services and value-type models.</li>
</ul>



<p class="wp-block-paragraph">When your components are organized this way, most Sendable errors never show up in the first place. I go deeper into this in <a href="https://www.swiftyplace.com/swiftui-architecture-swift-concurrency" target="_blank" rel="noreferrer noopener">How I Structure SwiftUI Apps So the Compiler Stays Quiet</a>.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What Sendable Errors Are Telling You</h2>



<p class="wp-block-paragraph">Sendable errors can be really annoying. But most of them say the same thing: mutable state is about to cross a boundary without protection.</p>



<p class="wp-block-paragraph">The most common causes I see are:</p>



<ul class="wp-block-list">
<li><strong>Class-based models.</strong> A model class with <code>var</code> properties passed from a service to a view model.</li>



<li><strong>Services with hidden state.</strong> A service class that keeps a cache or a counter inside.</li>



<li><strong>Closures capturing mutable variables.</strong> A <code>var</code> captured by a <code>Task</code> or a callback.</li>
</ul>



<p class="wp-block-paragraph">So before adding annotations to make an error go away, ask where this state should live. Often the fix is to turn a class into a struct, move state into an actor, or make a service stateless.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The Escape Hatches</h2>



<p class="wp-block-paragraph">Swift gives you ways to turn off the checks: <code>@unchecked Sendable</code> for types and <code>nonisolated(unsafe)</code> for variables.</p>



<p class="wp-block-paragraph">They have legitimate uses. If you protect a class&#8217;s state with a lock yourself, <code>@unchecked Sendable</code> tells the compiler you&#8217;ve got it covered. With <code>Mutex</code> from the Synchronization framework, you often don&#8217;t even need that, because <code>Mutex</code> itself is Sendable, so a final class that stores its state in a <code>let</code> mutex can be checked by the compiler.</p>



<p class="wp-block-paragraph">But if you use these just to silence errors, you&#8217;re bringing back exactly the kind of crash from the beginning of this post. The compiler stops checking, and you&#8217;re back to random crashes you can&#8217;t reproduce.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why Some Non-Sendable Code Still Compiles</h2>



<p class="wp-block-paragraph">At some point, you&#8217;ll notice that a non-Sendable value crosses a boundary and the compiler doesn&#8217;t complain. That&#8217;s not a bug.</p>



<p class="wp-block-paragraph">Since Swift 6, the compiler can see when you pass a non-Sendable value to another isolation domain and never use it again in the original one. If you hand it off completely, nothing is shared, so there&#8217;s no risk of a data race. The rules haven&#8217;t changed: the compiler is just smart enough to see that the state isn&#8217;t actually shared.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Wrap-Up</h2>



<p class="wp-block-paragraph">Data races used to be some of the hardest bugs to find. They were random, they hid behind misleading crash reports, and preventing them depended on never forgetting a single lock.</p>



<p class="wp-block-paragraph">Sendable changes that. It turns those runtime crashes into compiler errors, on your machine, before your users ever see them. The errors can be annoying, but every one of them points to state that could have crashed your app.</p>



<p class="wp-block-paragraph">Think about where your state lives, whether it&#8217;s shared, and how it&#8217;s protected. Once you do, Sendable stops feeling like a fight with the compiler.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<h3 class="wp-block-heading">Further Reading</h3>



<ul class="wp-block-list">
<li><a href="https://developer.apple.com/videos/play/wwdc2022/110351/" target="_blank" rel="noopener">Eliminate data races using Swift Concurrency</a> (WWDC22)</li>



<li><a href="https://developer.apple.com/videos/play/wwdc2021/10133/" target="_blank" rel="noopener">Protect mutable state with Swift actors</a> (WWDC21)</li>



<li><a href="https://developer.apple.com/videos/play/wwdc2024/10169/" target="_blank" rel="noopener">Migrate your app to Swift 6</a> (WWDC24)</li>



<li><a href="https://developer.apple.com/videos/play/wwdc2025/268/" target="_blank" rel="noopener">Embracing Swift concurrency</a> (WWDC25)</li>



<li><a href="https://www.swift.org/migration/documentation/migrationguide/" target="_blank" rel="noopener">Swift 6 migration guide</a></li>



<li><a href="https://www.swiftyplace.com/swiftui-architecture-swift-concurrency" target="_blank" rel="noreferrer noopener">How I Structure SwiftUI Apps So the Compiler Stays Quiet</a>.</li>
</ul>
</blockquote>



<p class="wp-block-paragraph"></p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/sendable-swift-concurrency">What Is Sendable in Swift? How Swift Concurrency Protects Your State</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/sendable-swift-concurrency/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Simple, Consistent, and a Little Repetitive</title>
		<link>https://www.swiftyplace.com/blog/simple-consistent-code?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=simple-consistent-code</link>
					<comments>https://www.swiftyplace.com/blog/simple-consistent-code#comments</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Tue, 06 Oct 2026 08:08:32 +0000</pubDate>
				<category><![CDATA[SwiftUI Architecture]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005720</guid>

					<description><![CDATA[<p>This might be slightly against the mainstream opinion, but I want to write code that works even if it looks boring. I don&#8217;t care if it&#8217;s clever. I don&#8217;t care if it uses every design ... <a title="Simple, Consistent, and a Little Repetitive" class="read-more" href="https://www.swiftyplace.com/blog/simple-consistent-code" aria-label="More on Simple, Consistent, and a Little Repetitive">Read more</a></p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/simple-consistent-code">Simple, Consistent, and a Little Repetitive</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">This might be slightly against the mainstream opinion, but I want to write code that works even if it looks boring. I don&#8217;t care if it&#8217;s clever. I don&#8217;t care if it uses every design pattern in the book. I care about one thing: what does it feel like to work with this code on a Tuesday afternoon when a ticket comes in and I need to change a tab icon?</p>



<p class="wp-block-paragraph">How many files do I open? How many hops before I find the right line? How confident am I that my change won&#8217;t break something three screens away?</p>



<p class="wp-block-paragraph">I&#8217;ve started measuring architecture by these small, boring moments. And what I&#8217;ve found — again and again — is that the codebases that feel best to work with share a few traits: they&#8217;re simple, they&#8217;re consistent, and they&#8217;re a little repetitive.</p>



<p class="wp-block-paragraph">That last one surprises people. We&#8217;ve been trained to think repetition is a code smell. I think it&#8217;s often the opposite.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The Allure of Abstraction</h2>



<p class="wp-block-paragraph">Let me show you a pattern I keep running into in SwiftUI projects: the global router. You can see an <a href="https://github.com/Dimillian/IcySky" target="_blank" rel="noopener">example project on Github here</a> which is a BlueSky client.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="678" src="https://www.swiftyplace.com/wp-content/uploads/2026/10/bluesky-client-1024x678.webp" alt="BlueSky client app implemented with router pattern in SwiftUI" class="wp-image-1005724" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/10/bluesky-client-1024x678.webp 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/10/bluesky-client-300x199.webp 300w, https://www.swiftyplace.com/wp-content/uploads/2026/10/bluesky-client-768x508.webp 768w, https://www.swiftyplace.com/wp-content/uploads/2026/10/bluesky-client-1536x1017.webp 1536w, https://www.swiftyplace.com/wp-content/uploads/2026/10/bluesky-client.webp 1837w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">It always starts the same way. You have four tabs, each needs a title and an icon, so you model it as an enum:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>enum AppTab: String, CaseIterable, Identifiable {
    case feed
    case notifications
    case profile
    case settings

    var id: String { rawValue }

    var title: String {
        switch self {
        case .feed: "Feed"
        case .notifications: "Notifications"
        case .profile: "Profile"
        case .settings: "Settings"
        }
    }

    var icon: String {
        switch self {
        case .feed: "house"
        case .notifications: "bell"
        case .profile: "person"
        case .settings: "gearshape.fill"
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">enum</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">AppTab</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">CaseIterable</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">Identifiable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> feed</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> notifications</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> profile</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> settings</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> { rawValue }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> title: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .feed</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Feed</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .notifications</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Notifications</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .profile</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Profile</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .settings</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Settings</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> icon: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .feed</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">house</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .notifications</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">bell</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .profile</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">person</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .settings</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">gearshape.fill</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Then your tab view loops over the cases:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct AppTabView: View {
    @State var selectedTab: AppTab = .feed

    var body: some View {
        TabView(selection: $selectedTab) {
            ForEach(AppTab.allCases) { tab in
                tab.rootView
                    .tabItem {
                        Label(tab.title, systemImage: tab.icon)
                    }
                    .tag(tab)
            }
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">AppTabView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> selectedTab: AppTab </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> .feed</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">TabView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">selection</span><span style="color: #F6F6F4">: $selectedTab) {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">ForEach</span><span style="color: #F6F6F4">(AppTab.allCases) { tab </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">                tab.rootView</span></span>
<span class="line"><span style="color: #F6F6F4">                    .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                        </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(tab.title, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: tab.icon)</span></span>
<span class="line"><span style="color: #F6F6F4">                    }</span></span>
<span class="line"><span style="color: #F6F6F4">                    .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(tab)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Clean. No repetition. The enum drives everything. This feels right.</p>



<p class="wp-block-paragraph">Then the app grows. You need navigation destinations, so you add a route enum:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>enum AppRoute: Hashable {
    case postDetail(Post)
    case userProfile(User)
    case settings
    case followers(User)
    case following(User)
    case editProfile
    // ... keeps growing
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">enum</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">AppRoute</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Hashable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> postDetail(Post)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> userProfile(User)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> settings</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> followers(User)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> following(User)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> editProfile</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// ... keeps growing</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">You need sheets, so you add another enum:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>enum SheetDestination: Identifiable {
    case auth
    case newPost
    case mediaViewer(MediaItem)
    case editProfile
    case reportContent(Post)

    var id: String { /* ... */ }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">enum</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">SheetDestination</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Identifiable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> auth</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> newPost</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> mediaViewer(MediaItem)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> editProfile</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> reportContent(Post)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> { </span><span style="color: #7B7F8B">/* ... */</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">And a centralized modifier to wire it all up:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct AppDestinationsModifier: ViewModifier {
    @Environment(Router.self) var router

    func body(content: Content) -> some View {
        content
            .navigationDestination(for: AppRoute.self) { route in
                switch route {
                case .postDetail(let post):
                    PostDetailView(post: post)
                case .userProfile(let user):
                    ProfileView(user: user)
                case .settings:
                    SettingsView()
                case .followers(let user):
                    FollowersListView(user: user)
                case .following(let user):
                    FollowingListView(user: user)
                case .editProfile:
                    EditProfileView()
                }
            }
            .sheet(item: $router.presentedSheet) { destination in
                switch destination {
                case .auth:
                    AuthenticationLoginView()
                case .newPost:
                    ComposerView()
                case .mediaViewer(let media):
                    MediaDetailView(media: media)
                case .editProfile:
                    EditProfileView()
                case .reportContent(let post):
                    ReportView(post: post)
                }
            }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">AppDestinationsModifier</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ViewModifier </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@Environment</span><span style="color: #F6F6F4">(Router.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> router</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">body</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">content</span><span style="color: #F6F6F4">: Content) </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        content</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: AppRoute.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { route </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> route {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">postDetail</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> post)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">PostDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">: post)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">userProfile</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> user)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">ProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">user</span><span style="color: #F6F6F4">: user)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .settings</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">SettingsView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">followers</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> user)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">FollowersListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">user</span><span style="color: #F6F6F4">: user)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">following</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> user)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">FollowingListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">user</span><span style="color: #F6F6F4">: user)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .editProfile</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">EditProfileView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">sheet</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">item</span><span style="color: #F6F6F4">: $router.presentedSheet) { destination </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> destination {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .auth</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">AuthenticationLoginView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .newPost</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">ComposerView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">mediaViewer</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> media)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">MediaDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">media</span><span style="color: #F6F6F4">: media)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .editProfile</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">EditProfileView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">reportContent</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> post)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">ReportView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">: post)</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">At this point, search your project for <code>Router</code> or <code>AppRoute</code>. You&#8217;ll find it in nearly every file. That innocent enum has become the most coupled dependency in your entire application.  A god object that everything needs and nothing can work without.</p>



<p class="wp-block-paragraph">Here&#8217;s the boring alternative. No enum. No router. Just tabs:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>TabView(selection: $selectedTab) {
    FeedView()
        .tabItem { Label("Feed", systemImage: "house") }
        .tag(AppTab.feed)

    NotificationsView()
        .tabItem { Label("Notifications", systemImage: "bell") }
        .tag(AppTab.notifications)

    ProfileView()
        .tabItem { Label("Profile", systemImage: "person") }
        .tag(AppTab.profile)

    SettingsView()
        .tabItem { Label("Settings", systemImage: "gearshape.fill") }
        .tag(AppTab.settings)
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">TabView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">selection</span><span style="color: #F6F6F4">: $selectedTab) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">FeedView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Feed</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">house</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.feed)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">NotificationsView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Notifications</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">bell</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.notifications)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProfileView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Profile</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">person</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.profile)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">SettingsView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Settings</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">gearshape.fill</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.settings)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">I have to repeat the code to write <code>NavigaitonStack</code> and the destinations for each Tab:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct FeedView: View {
   ...

    var body: some View {
        NavigationStack(path: $path) {
            FeedsListView()
                .navigationDestination(for: FeedDestination.self) { destination in
                    switch destination {
                        case .feed(let feedItem):
                            PostsFeedView(client: client, feedItem: feedItem)
                        case .post(let post):
                            PostDetailView(post: post)
                        case .timeline:
                            PostsTimelineView(client: client)
                        case .profile(let profile):
                            ProfileView(profile: profile)
                        case .profilePosts(let profile, let filter):
                            PostsProfileView(client: client, profile: profile, filter: filter)
                        case .profileLikes(let profile):
                            PostsLikesView(client: client, profile: profile)
                    }
                }
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">FeedView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">   </span><span style="color: #F286C4">...</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">NavigationStack</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">path</span><span style="color: #F6F6F4">: $path) {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">FeedsListView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: FeedDestination.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { destination </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> destination {</span></span>
<span class="line"><span style="color: #F6F6F4">                        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">feed</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> feedItem)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                            </span><span style="color: #97E1F1">PostsFeedView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">client</span><span style="color: #F6F6F4">: client, </span><span style="color: #97E1F1">feedItem</span><span style="color: #F6F6F4">: feedItem)</span></span>
<span class="line"><span style="color: #F6F6F4">                        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> post)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                            </span><span style="color: #97E1F1">PostDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">: post)</span></span>
<span class="line"><span style="color: #F6F6F4">                        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .timeline</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                            </span><span style="color: #97E1F1">PostsTimelineView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">client</span><span style="color: #F6F6F4">: client)</span></span>
<span class="line"><span style="color: #F6F6F4">                        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                            </span><span style="color: #97E1F1">ProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">: profile)</span></span>
<span class="line"><span style="color: #F6F6F4">                        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profilePosts</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile, </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> filter)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                            </span><span style="color: #97E1F1">PostsProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">client</span><span style="color: #F6F6F4">: client, </span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">: profile, </span><span style="color: #97E1F1">filter</span><span style="color: #F6F6F4">: filter)</span></span>
<span class="line"><span style="color: #F6F6F4">                        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profileLikes</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                            </span><span style="color: #97E1F1">PostsLikesView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">client</span><span style="color: #F6F6F4">: client, </span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">: profile)</span></span>
<span class="line"><span style="color: #F6F6F4">                    }</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">More repetitive? Sure. But now let me show you what happens when we start working with both.</p>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Ticket 1: Change a Tab Icon</h2>



<p class="wp-block-paragraph">First ticket of the day. The Settings tab uses a filled gear icon. Design wants the outline version instead. A one-line change.</p>



<p class="wp-block-paragraph"><strong>The direct version.</strong> I open the app file, I see the tabs, I see <code>"gearshape.fill"</code>, I change it to <code>"gearshape"</code>. One file. Done. The icon is right next to the view it belongs to. I didn&#8217;t have to think.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>TabView(selection: $selectedTab) {
    FeedListView()
        .tabItem { Label("Feed", systemImage: "house") }
        .tag(AppTab.feed)

    ...

    SettingsView()
        .tabItem { Label("Settings", systemImage: "gearshape.fill") } // change this line
        .tag(AppTab.settings)
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">TabView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">selection</span><span style="color: #F6F6F4">: $selectedTab) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">FeedListView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Feed</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">house</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.feed)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">...</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">SettingsView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Settings</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">gearshape.fill</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) } </span><span style="color: #7B7F8B">// change this line</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.settings)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph"><strong>The router version.</strong> I open the app file, I find the <code>TabView</code>, I see <code>ForEach(AppTab.allCases)</code>. The label references <code>tab.icon</code>. So I navigate to the <code>AppTab</code> enum, find the <code>icon</code> computed property, scroll to the <code>.settings</code> case, and change it there.</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>enum AppTab: String, CaseIterable, Identifiable {
    case feed
    case notifications
    case profile
    case settings

    var id: String { rawValue }

    var title: String {
       ...
    }

    var icon: String {
        switch self {
        case .feed: "house"
        case .notifications: "bell"
        case .profile: "person"
        case .settings: "gearshape.fill" // change this line
        }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">enum</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">AppTab</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">CaseIterable</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">Identifiable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> feed</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> notifications</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> profile</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> settings</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> id: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> { rawValue }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> title: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">       </span><span style="color: #F286C4">...</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> icon: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE; font-style: italic">self</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .feed</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">house</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .notifications</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">bell</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .profile</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">person</span><span style="color: #DEE492">&quot;</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .settings</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">gearshape.fill</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4"> </span><span style="color: #7B7F8B">// change this line</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Same result, but I had to open a second file and trace through a level of indirection to get there. The icon doesn&#8217;t live where the tab is. It lives on a property, on an enum case, in a model file.</p>



<p class="wp-block-paragraph">On its own, this is nothing. One extra file. But these &#8220;nothing&#8221; moments happen twenty times a day.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Ticket 2: Reorder the Tabs</h2>



<p class="wp-block-paragraph">Product wants Profile before Notifications. The tab bar should read: Feed, Profile, Notifications, Settings.</p>



<p class="wp-block-paragraph"><strong>The direct version.</strong> I move the Profile code block above the Notifications block:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>TabView(selection: $selectedTab) {
    FeedListView()
        .tabItem { Label("Feed", systemImage: "house") }
        .tag(AppTab.feed)

    ProfileView()                // moved up
        .tabItem { Label("Profile", systemImage: "person") }
        .tag(AppTab.profile)

    NotificationsView()          // moved down
        .tabItem { Label("Notifications", systemImage: "bell") }
        .tag(AppTab.notifications)

    SettingsView()
        .tabItem { Label("Settings", systemImage: "gearshape") }
        .tag(AppTab.settings)
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #97E1F1">TabView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">selection</span><span style="color: #F6F6F4">: $selectedTab) {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">FeedListView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Feed</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">house</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.feed)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProfileView</span><span style="color: #F6F6F4">()                </span><span style="color: #7B7F8B">// moved up</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Profile</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">person</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.profile)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">NotificationsView</span><span style="color: #F6F6F4">()          </span><span style="color: #7B7F8B">// moved down</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Notifications</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">bell</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.notifications)</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">SettingsView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tabItem</span><span style="color: #F6F6F4"> { </span><span style="color: #97E1F1">Label</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Settings</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1">systemImage</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">gearshape</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">(AppTab.settings)</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Declaration order is display order. It&#8217;s so obvious it barely requires thought.</p>



<p class="wp-block-paragraph"><strong>The router version.</strong> The <code>TabView</code> uses <code>ForEach(AppTab.allCases)</code>. The <code>allCases</code> property comes from the <code>CaseIterable</code> protocol, which returns cases in the order they&#8217;re declared in the enum. So to reorder the tabs on screen, I reorder the cases in the enum definition:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>enum AppTab: String, CaseIterable, Identifiable {
    case feed
    case profile        // moved up
    case notifications  // moved down
    case settings
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">enum</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">AppTab</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">CaseIterable</span><span style="color: #F6F6F4">, </span><span style="color: #97E1F1; font-style: italic">Identifiable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> feed</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> profile        </span><span style="color: #7B7F8B">// moved up</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> notifications  </span><span style="color: #7B7F8B">// moved down</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> settings</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Stop and think about what just happened. I changed a model definition. The declaration order of an enum&#8217;s cases to achieve a UI change. Nothing in the <code>TabView</code> tells you this. Nothing in the view layer hints at it. The connection between &#8220;what I see on screen&#8221; and &#8220;what I need to change in code&#8221; passes through a protocol conformance that&#8217;s completely invisible unless you already know how <code>CaseIterable</code> works.</p>



<p class="wp-block-paragraph">If you know this pattern, you&#8217;ll find it in five minutes. If you&#8217;re a junior developer or an intern picking up this ticket on a Friday afternoon, you might spend half an hour wondering how to reorder the views in the <code>TabView</code> with a <code>ForEach</code>.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Ticket 3: Add a New Screen</h2>



<p class="wp-block-paragraph">Here&#8217;s where the real cost shows up. The Profile tab needs a new screen: a &#8220;Blocked Users&#8221; list, accessible from the user&#8217;s profile page. Straightforward feature work.</p>



<p class="wp-block-paragraph">But first, let me show you what the router pattern has grown into by this point. As the app added more screens, all navigation destinations were centralized into a single modifier applied to the entire tab view:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>public struct AppDestinations: ViewModifier {
    public func body(content: Content) -> some View {
        content
            .navigationDestination(for: RouterDestination.self) { destination in
                switch destination {
                case .feed(let feedItem):
                    PostsFeedView(feedItem: feedItem)
                case .post(let post):
                    PostDetailView(post: post)
                case .timeline:
                    PostsTimelineView()
                case .profile(let profile):
                    ProfileView(profile: profile)
                case .profilePosts(let profile, let filter):
                    PostsProfileView(profile: profile, filter: filter)
                case .profileLikes(let profile):
                    PostsLikesView(profile: profile)
                }
            }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">public</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">AppDestinations</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">ViewModifier </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">public</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">func</span><span style="color: #F6F6F4"> </span><span style="color: #62E884">body</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">content</span><span style="color: #F6F6F4">: Content) </span><span style="color: #F286C4">-&gt;</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        content</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: RouterDestination.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { destination </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">switch</span><span style="color: #F6F6F4"> destination {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">feed</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> feedItem)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">PostsFeedView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">feedItem</span><span style="color: #F6F6F4">: feedItem)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> post)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">PostDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">: post)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .timeline</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">PostsTimelineView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">ProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">: profile)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profilePosts</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile, </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> filter)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">PostsProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">: profile, </span><span style="color: #97E1F1">filter</span><span style="color: #F6F6F4">: filter)</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profileLikes</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile)</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">                    </span><span style="color: #97E1F1">PostsLikesView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">: profile)</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Look at this switch statement for a moment. Six destinations. Which ones belong to the Feed tab? Which ones belong to the Profile tab? Can the Feed tab navigate to <code>profileLikes</code>? Can the Profile tab navigate to <code>timeline</code>? You can&#8217;t tell. Everything is flattened into one bucket with no indication of which tab uses what.</p>



<p class="wp-block-paragraph">So, back to the ticket. To add the Blocked Users screen, I need to:</p>



<ol class="wp-block-list">
<li>Add a new case to the <code>RouterDestination</code> enum</li>



<li>Add a new branch to this switch statement</li>



<li>Hope that I&#8217;m not accidentally making this screen reachable from tabs that shouldn&#8217;t have it</li>
</ol>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>enum RouterDestination: Hashable {
    case feed(FeedItem)
    case post(Post)
    case timeline
    case profile(Profile)
    case profilePosts(Profile, PostFilter)
    case profileLikes(Profile)
    case blockedUsers              // new
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">enum</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">RouterDestination</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">Hashable </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> feed(FeedItem)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> post(Post)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> timeline</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> profile(Profile)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> profilePosts(Profile, PostFilter)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> profileLikes(Profile)</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> blockedUsers              </span><span style="color: #7B7F8B">// new</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>case .blockedUsers:
    BlockedUsersView()</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .blockedUsers</span><span style="color: #F286C4">:</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">BlockedUsersView</span><span style="color: #F6F6F4">()</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Done? Technically yes. But think about what just happened. I added a Profile feature to a global modifier that the Feed tab, the Notifications tab, and the Settings tab all share. Nothing in this code says &#8220;this destination belongs to Profile.&#8221; It just sits in the pile with everything else.</p>



<p class="wp-block-paragraph">And think about what this file looks like two years from now. The app keeps growing. More screens, more destinations:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>case .feed(let feedItem): PostsFeedView(feedItem: feedItem)
case .post(let post): PostDetailView(post: post)
case .timeline: PostsTimelineView()
case .profile(let profile): ProfileView(profile: profile)
case .profilePosts(let profile, let filter): PostsProfileView(...)
case .profileLikes(let profile): PostsLikesView(profile: profile)
case .blockedUsers: BlockedUsersView()
case .search(let query): SearchResultsView(query: query)
case .followers(let user): FollowersListView(user: user)
case .following(let user): FollowingListView(user: user)
case .editProfile: EditProfileView()
case .thread(let post): ThreadView(post: post)
case .hashtag(let tag): HashtagFeedView(tag: tag)
case .quotePosts(let post): QuotePostsView(post: post)
case .mutedWords: MutedWordsView()</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">feed</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> feedItem)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">PostsFeedView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">feedItem</span><span style="color: #F6F6F4">: feedItem)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> post)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">PostDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">: post)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .timeline</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">PostsTimelineView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">: profile)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profilePosts</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile, </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> filter)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">PostsProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">...</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">profileLikes</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> profile)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">PostsLikesView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">profile</span><span style="color: #F6F6F4">: profile)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .blockedUsers</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">BlockedUsersView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">search</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> query)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">SearchResultsView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">query</span><span style="color: #F6F6F4">: query)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">followers</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> user)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FollowersListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">user</span><span style="color: #F6F6F4">: user)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">following</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> user)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">FollowingListView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">user</span><span style="color: #F6F6F4">: user)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .editProfile</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">EditProfileView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">thread</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> post)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ThreadView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">: post)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">hashtag</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> tag)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">HashtagFeedView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">tag</span><span style="color: #F6F6F4">: tag)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .</span><span style="color: #97E1F1">quotePosts</span><span style="color: #F6F6F4">(</span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> post)</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">QuotePostsView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">: post)</span></span>
<span class="line"><span style="color: #F286C4">case</span><span style="color: #F6F6F4"> .mutedWords</span><span style="color: #F286C4">:</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">MutedWordsView</span><span style="color: #F6F6F4">()</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Fifteen cases. All in one switch. Every developer on the team edits this same file whenever they add a screen, anywhere in the app. Profile developer and Feed developer and Settings developer all touching the same enum, the same switch, the same modifier. Merge conflicts become routine. And the worst part: a change to profile navigation could affect the feed tab, and you wouldn&#8217;t know until runtime.</p>



<p class="wp-block-paragraph">Now imagine a real production app. Not fifteen screens. Fifty. A hundred. This file becomes an archaeological dig site, and every new screen makes the dig deeper.</p>



<p class="wp-block-paragraph">The root cause is structural. You&#8217;ve taken navigation that is inherently local (&#8220;from this screen, you can go to these screens&#8221;) and made it global. Global things don&#8217;t scale.</p>



<p class="wp-block-paragraph"><strong>Now the same ticket with the boring approach.</strong> Each tab has its own <code>NavigationStack</code> and declares its own destinations:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>// Feed tab
NavigationStack {
    FeedListView()
        .navigationDestination(for: Post.self) { post in
            PostDetailView(post: post)
        }
        .navigationDestination(for: FeedItem.self) { item in
            PostsFeedView(feedItem: item)
        }
}

// Profile tab
NavigationStack {
    ProfileView()
        .navigationDestination(for: PostFilter.self) { filter in
            PostsProfileView(filter: filter)
        }
        .navigationDestination(for: User.self) { user in
            UserDetailView(user: user)
        }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #7B7F8B">// Feed tab</span></span>
<span class="line"><span style="color: #97E1F1">NavigationStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">FeedListView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: Post.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { post </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">PostDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">post</span><span style="color: #F6F6F4">: post)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: FeedItem.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { item </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">PostsFeedView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">feedItem</span><span style="color: #F6F6F4">: item)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #7B7F8B">// Profile tab</span></span>
<span class="line"><span style="color: #97E1F1">NavigationStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProfileView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: PostFilter.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { filter </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">PostsProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">filter</span><span style="color: #F6F6F4">: filter)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: User.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { user </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">UserDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">user</span><span style="color: #F6F6F4">: user)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">Adding the Blocked Users screen? I add a <code>.navigationDestination</code> to the Profile tab&#8217;s stack:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>// Profile tab
NavigationStack {
    ProfileView()
        .navigationDestination(for: PostFilter.self) { filter in
            PostsProfileView(filter: filter)
        }
        .navigationDestination(for: User.self) { user in
            UserDetailView(user: user)
        }
        .navigationDestination(for: BlockedUsersRoute.self) { _ in
            BlockedUsersView()    // new, only here
        }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #7B7F8B">// Profile tab</span></span>
<span class="line"><span style="color: #97E1F1">NavigationStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #97E1F1">ProfileView</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: PostFilter.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { filter </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">PostsProfileView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">filter</span><span style="color: #F6F6F4">: filter)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: User.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { user </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">UserDetailView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">user</span><span style="color: #F6F6F4">: user)</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">        .</span><span style="color: #97E1F1">navigationDestination</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: BlockedUsersRoute.</span><span style="color: #F286C4">self</span><span style="color: #F6F6F4">) { </span><span style="color: #BF9EEE">_</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">in</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">BlockedUsersView</span><span style="color: #F6F6F4">()    </span><span style="color: #7B7F8B">// new, only here</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">That&#8217;s it. One file. One tab. The Feed tab doesn&#8217;t know about it. The Notifications tab isn&#8217;t affected. No shared enum to extend. No shared switch to edit. No merge conflict with the developer adding search to the Feed tab.</p>



<p class="wp-block-paragraph">I can look at the Profile tab&#8217;s code and see every screen it can navigate to. I can look at the Feed tab&#8217;s code and see every screen it can navigate to. The structure tells me the truth. I don&#8217;t need to cross-reference a central registry to figure out which destinations belong where.</p>



<p class="wp-block-paragraph">Yes, I wrote <code>NavigationStack</code> multiple times. Yes, if both Feed and Profile can navigate to <code>PostDetailView</code>, it shows up in two places. That&#8217;s more code. That&#8217;s more repetition. And I&#8217;ll take that trade every single time, because the alternative is a centralized switch statement that grows without limit and tells me nothing about which feature owns which screen.</p>



<p class="wp-block-paragraph">The router version scales by making one file bigger and bigger. The direct version scales by keeping each tab small and independent. One approach has a ceiling. The other doesn&#8217;t.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The Abstraction Tax</h2>



<p class="wp-block-paragraph">There&#8217;s a pattern running through all three of these tickets. The router version always takes more hops. Always requires opening more files. Always demands that I understand the system before I can make a simple change: the enum, the protocol conformance, the centralized modifier, the destination switch.</p>



<p class="wp-block-paragraph">Each individual layer feels small and reasonable when you add it. &#8220;I&#8217;ll just put the icons on the enum.&#8221; &#8220;I&#8217;ll just centralize the destinations.&#8221; &#8220;I&#8217;ll just use <code>CaseIterable</code> for the tab order.&#8221; Each one is a defensible decision in isolation.</p>



<p class="wp-block-paragraph">But they stack. And the cost isn&#8217;t paid by the person who built the abstraction. They understand it perfectly, they just created it. The cost is paid by every person who reads the code afterward. The next developer on the team. The new hire trying to onboard. You, three months from now, after you&#8217;ve forgotten the details.</p>



<p class="wp-block-paragraph">And those three tickets? They&#8217;re not edge cases. They&#8217;re the most common kind of work in any codebase. Change an icon. Reorder some items. Add a new screen. This is what a normal week looks like. If your architecture makes normal work harder, it&#8217;s not helping you.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Repetition Isn&#8217;t the Enemy</h2>



<p class="wp-block-paragraph">At this point you might be looking at the boring version with four <code>NavigationStack</code> declarations, <code>PostDetailView</code> showing up in two tabs, <code>.tabItem</code> written out four times, and thinking: isn&#8217;t this exactly what DRY tells me not to do?</p>



<p class="wp-block-paragraph">DRY stands for &#8220;Don&#8217;t Repeat Yourself.&#8221; The idea is straightforward: if the same piece of knowledge exists in two places, eventually one gets updated and the other doesn&#8217;t. You define a tax rate in your checkout flow and again in your invoice generator. Someone updates checkout to 10%. Nobody touches the invoice. It still says 8%. Customers get charged one thing and invoiced another. That&#8217;s a real bug, and DRY prevents it. Put the tax rate in one place, reference it everywhere.</p>



<p class="wp-block-paragraph">But a <code>NavigationStack</code> in the Feed tab and a <code>NavigationStack</code> in the Profile tab aren&#8217;t the same piece of knowledge defined twice. They&#8217;re two different things that happen to use the same API. Each one is scoped to its own tab. Each one owns different destinations. They&#8217;re configured independently and they change independently. There&#8217;s nothing to drift apart because they were never the same thing.</p>



<p class="wp-block-paragraph">Same with <code>PostDetailView</code> appearing as a destination in two tabs. That feels like duplication, but what would actually go wrong? If you change <code>PostDetailView(post:)</code> to <code>PostDetailView(post:showComments:)</code>, every call site that&#8217;s outdated fails to compile. You get a red error in both tabs. You fix them in thirty seconds. The compiler already prevents the drift that DRY is designed to prevent.</p>



<p class="wp-block-paragraph">The discomfort with the boring version is real, but it&#8217;s pattern recognition misfiring. We&#8217;ve trained ourselves to see any repetition as a problem. Sometimes repetition is just structure. Four tabs, four navigation stacks. That&#8217;s not duplication. That&#8217;s how tabs work.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">What &#8220;Simple, Consistent, and a Little Repetitive&#8221; Means</h2>



<p class="wp-block-paragraph">When I say I prefer boring code, I mean something specific.</p>



<p class="wp-block-paragraph"><strong>Simple</strong> means the path from &#8220;what I see on screen&#8221; to &#8220;what I change in code&#8221; is short. If I see a gear icon in the tab bar, I should find <code>"gearshape"</code> within one or two file hops. Not on a computed property on an enum case in a model file.</p>



<p class="wp-block-paragraph"><strong>Consistent</strong> means the same pattern is used the same way everywhere. Every tab has its own <code>NavigationStack</code>. Every tab declares its own destinations locally. You learn the pattern once, and you can predict where things live in any part of the app.</p>



<p class="wp-block-paragraph">New developer onboarding becomes a thirty-second conversation. &#8220;Each tab has a <code>NavigationStack</code>. Destinations are declared in the tab that uses them.&#8221; That&#8217;s the architecture. There&#8217;s no router to learn, no centralized modifier to memorize, no <code>CaseIterable</code> trick to discover.</p>



<p class="wp-block-paragraph"><strong>A little repetitive</strong> means I&#8217;ll write <code>NavigationStack</code> four times. I&#8217;ll write <code>.tabItem { Label(...) }</code> four times. I&#8217;ll declare destinations per tab instead of in a shared modifier. And I won&#8217;t feel bad about it, because each of those repetitions makes the code more local, more self-contained, and more obvious.</p>



<p class="wp-block-paragraph">Every time I&#8217;m tempted to extract a shared abstraction, I ask myself: does this repetition actually cause problems? Will these four <code>NavigationStack</code> declarations drift apart in a way that matters? Or am I just uncomfortable with code that looks a little verbose?</p>



<p class="wp-block-paragraph">Usually it&#8217;s the discomfort. And I&#8217;ve learned to sit with that discomfort, because the alternative, another layer of indirection, another file hop, another concept someone has to learn, is worse.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The Friday Afternoon Test</h2>



<p class="wp-block-paragraph">Here&#8217;s the test I keep coming back to. It&#8217;s Friday afternoon. You&#8217;ve been coding all week. You&#8217;re tired. A ticket comes in. Change an icon. Reorder tabs. Add a new screen to the Profile tab.</p>



<p class="wp-block-paragraph">In the simple, boring codebase, you open the relevant file, you see the code laid out directly in front of you, you make the change. Ten minutes, including running the app to verify.</p>



<p class="wp-block-paragraph">In the clever, abstracted codebase, you open the file and the actual content is somewhere else. Driven by an enum. Routed through a modifier. Ordered by a protocol conformance. You start jumping. File to file, layer to layer, holding the mental thread of where you started. You find the right spot eventually, but it took three times as long and you&#8217;re still not sure your change won&#8217;t ripple into a tab you never meant to touch.</p>



<p class="wp-block-paragraph">Both get you to the same result. One of them will still work fine when the app has fifty screens and three new developers who&#8217;ve never seen the codebase. The other will have a <code>RouterDestination</code> enum with eighty cases and a <code>AppDestinations</code> modifier that nobody wants to touch.</p>



<p class="wp-block-paragraph">Don&#8217;t add abstractions you don&#8217;t need. Prefer code you can follow without a tour guide, even if it means writing <code>NavigationStack</code> four times. The app doesn&#8217;t care how clever your architecture is. It cares whether you can ship the next ticket without breaking the last one.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Further Reading</h2>



<p class="wp-block-paragraph"><strong>Navigation in SwiftUI.</strong> Apple&#8217;s documentation on <code>NavigationStack</code> and <code>navigationDestination</code> is worth reading carefully. A lot of the router patterns people reach for exist because they carried habits over from other frameworks. SwiftUI&#8217;s declarative navigation already solves many of the problems routers were built for. Sometimes the best abstraction is the one the framework already gives you.</p>



<ul class="wp-block-list">
<li>WWDC22 Session 10054, <a href="https://developer.apple.com/videos/play/wwdc2022/10054/" target="_blank" rel="noopener">&#8220;The SwiftUI cookbook for navigation&#8221;</a> (shows <code>NavigationStack</code> and <code>navigationDestination</code> in practice, the API the &#8220;boring&#8221; version is built on)</li>



<li>Apple&#8217;s <code><a href="https://developer.apple.com/documentation/swiftui/navigationstack" target="_blank" rel="noopener">NavigationStack</a></code></li>
</ul>



<p class="wp-block-paragraph"><strong>Coordinator pattern.</strong> If your navigation decisions depend on logic that doesn&#8217;t belong in a view, coordinators are worth exploring. A background network request finishes and you need to present a specific sheet based on the result. A push notification arrives and you need to navigate somewhere. Who holds that logic? A coordinator sits above the view layer, owns the navigation state, and makes those decisions. The key: don&#8217;t build one global coordinator. Build small, scoped ones per feature. For most simple apps you won&#8217;t need them, but when navigation starts depending on async events and external triggers, they give you a clean home for that logic.</p>



<p class="wp-block-paragraph"><strong>Modularization with Swift packages.</strong> The global router is a modularization killer. It imports every screen in the app, which means it depends on every feature. You can&#8217;t extract your Profile feature into its own package without the router package also depending on it, and on Feed, and on Settings, and on everything else. The approach in this post avoids that entirely. Each tab already owns its navigation and only knows about its own screens. When the time comes to split into packages, the boundaries are already there.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/simple-consistent-code">Simple, Consistent, and a Little Repetitive</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/simple-consistent-code/feed</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>What is @State actually doing in SwiftUI</title>
		<link>https://www.swiftyplace.com/blog/swiftui-state-property-wrapper?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=swiftui-state-property-wrapper</link>
					<comments>https://www.swiftyplace.com/blog/swiftui-state-property-wrapper#respond</comments>
		
		<dc:creator><![CDATA[Karin Prater]]></dc:creator>
		<pubDate>Tue, 22 Sep 2026 10:23:44 +0000</pubDate>
				<category><![CDATA[SwiftUI]]></category>
		<guid isPermaLink="false">https://www.swiftyplace.com/?p=1005694</guid>

					<description><![CDATA[<p>The SwiftUI property wrapper @State doesn't store values in your view struct. It creates a node in SwiftUI's AttributeGraph, and if you don't understand that, you'll write bugs you can't explain. Here's what's actually happening and how to fix the most common mistake.</p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/swiftui-state-property-wrapper">What is @State actually doing in SwiftUI</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">When you write <code>@State private var count = 0</code> in a SwiftUI view, it looks like a normal property declaration. It&#8217;s not. That line doesn&#8217;t store a value inside your view struct. It tells SwiftUI to create a node in something called the <a href="https://www.swiftyplace.com/blog/the-attributegraph-the-engine-behind-every-swiftui-view" data-type="link" data-id="https://www.swiftyplace.com/blog/the-attributegraph-the-engine-behind-every-swiftui-view">AttributeGraph</a>. A runtime data structure that holds your actual state and tracks every dependency in your view hierarchy.</p>



<div style="height:63px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The AttributeGraph</h2>



<p class="wp-block-paragraph">SwiftUI maintains an internal data structure called the AttributeGraph. It&#8217;s a directed graph where every node represents a value. A view&#8217;s body, a modifier, a piece of state, and the edges are the dependencies between them.</p>



<p class="wp-block-paragraph">When a state value changes, SwiftUI walks the edges to find exactly which nodes are affected and only reevaluates those. This is how SwiftUI decides whether your view&#8217;s body needs to run again. It&#8217;s not diffing a DOM. It&#8217;s tracking state dependencies.</p>



<p class="wp-block-paragraph">If you&#8217;ve ever opened Instruments and looked at the SwiftUI cause-and-effect graph, you&#8217;ve seen a slice of this. That&#8217;s the AttributeGraph. The nodes, the edges, the dependency chains. That&#8217;s what&#8217;s running your app.</p>



<div style="height:38px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What does the @State Property Wrapper do?</h2>



<p class="wp-block-paragraph">When SwiftUI sees <code>@State</code>, it creates a node in the AttributeGraph to store this value. Here&#8217;s what that looks like in practice:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct ParentView: View {
    // This creates a node in the AttributeGraph that holds the Int.
    // ParentView.body has a dependency edge to this node.
    @State private var counter = 0

    var body: some View {
        VStack {
            // `counter` flows into ChildView as an input — another edge in the graph.
            // When counter changes, SwiftUI walks the edges and knows
            // both ParentView.body and ChildView.body need reevaluation.
            ChildView(label: "You selected (counter)")
            Button("Increment: (counter)") { counter += 1 }
        }
    }
}

struct ChildView: View {
    let label: String  // an input edge from the parent's node
    var body: some View {
        Text(label)
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ParentView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// This creates a node in the AttributeGraph that holds the Int.</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #7B7F8B">// ParentView.body has a dependency edge to this node.</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> counter </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">0</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">VStack</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #7B7F8B">// `counter` flows into ChildView as an input — another edge in the graph.</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #7B7F8B">// When counter changes, SwiftUI walks the edges and knows</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #7B7F8B">// both ParentView.body and ChildView.body need reevaluation.</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">ChildView</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">label</span><span style="color: #F6F6F4">: </span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">You selected (counter)</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">)</span></span>
<span class="line"><span style="color: #F6F6F4">            </span><span style="color: #97E1F1">Button</span><span style="color: #F6F6F4">(</span><span style="color: #DEE492">&quot;</span><span style="color: #E7EE98">Increment: (counter)</span><span style="color: #DEE492">&quot;</span><span style="color: #F6F6F4">) { counter </span><span style="color: #F286C4">+=</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">1</span><span style="color: #F6F6F4"> }</span></span>
<span class="line"><span style="color: #F6F6F4">        }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">ChildView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> label: </span><span style="color: #97E1F1; font-style: italic">String</span><span style="color: #F6F6F4">  </span><span style="color: #7B7F8B">// an input edge from the parent&#39;s node</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">Text</span><span style="color: #F6F6F4">(label)</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:23px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph"><code>@State</code> doesn&#8217;t store the value in <code>ParentView</code>. <code>ParentView</code> is a struct. A short-lived value type. It gets created, body gets evaluated, and it goes away. The actual <code>counter</code> value persists in the AttributeGraph across those struct lifetimes. The struct dies, the node in the graph doesn&#8217;t.</p>



<p class="wp-block-paragraph">If you&#8217;ve opened Instruments and looked at the cause and effect graph, you&#8217;ve seen a slice of this. That&#8217;s the AttributeGraph. The nodes, the edges, the dependency chains. That&#8217;s what&#8217;s running your app.</p>



<figure class="gb-block-image gb-block-image-5de8c37d"><img loading="lazy" decoding="async" width="1702" height="818" class="gb-image gb-image-5de8c37d" src="https://www.swiftyplace.com/wp-content/uploads/2026/09/swiftui-instrument-example-2-3.jpg" alt="" title="swiftui-instrument-example-2-3" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/09/swiftui-instrument-example-2-3.jpg 1702w, https://www.swiftyplace.com/wp-content/uploads/2026/09/swiftui-instrument-example-2-3-300x144.jpg 300w, https://www.swiftyplace.com/wp-content/uploads/2026/09/swiftui-instrument-example-2-3-1024x492.jpg 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/09/swiftui-instrument-example-2-3-768x369.jpg 768w, https://www.swiftyplace.com/wp-content/uploads/2026/09/swiftui-instrument-example-2-3-1536x738.jpg 1536w" sizes="auto, (max-width: 1702px) 100vw, 1702px" /></figure>



<p class="wp-block-paragraph">In the right sidebar, inside Instruments, you can see more details from the AttributeGraph and follow&nbsp;the updating process.</p>



<p class="wp-block-paragraph">(A) This is updating an existing view</p>



<p class="wp-block-paragraph">(B) The source node is a state node from ParentView._counter to</p>



<p class="wp-block-paragraph">(C) Destination ParentView.body</p>



<div style="height:54px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">What happens when you ignore this</h2>



<p class="wp-block-paragraph">This matters the moment you try to initialize <code>@State</code> from a parent value. You have a <code>NavigationSplitView</code>. The sidebar shows a list of users. When someone selects one, the detail pane loads that user&#8217;s profile. The detail view needs a <code>userID</code> from the parent to get started.</p>



<p class="wp-block-paragraph">The natural instinct is to initialize the view model in <code>init</code> and pass the value there:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(1 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct DetailView: View {
    @State private var viewModel: ViewModel

    init(userID: UUID) {
        _viewModel = State(wrappedValue: ViewModel(id: userID))
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">DetailView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel: ViewModel</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">init</span><span style="color: #F6F6F4">(</span><span style="color: #62E884; font-style: italic">userID</span><span style="color: #F6F6F4">: UUID) {</span></span>
<span class="line"><span style="color: #F6F6F4">        _viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">State</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">wrappedValue</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1">ViewModel</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: userID))</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:29px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph">It works the first time. You select a user, the detail pane loads. Then you select a different user. The detail pane doesn&#8217;t update. It&#8217;s still showing the first user.</p>



<figure class="gb-block-image gb-block-image-3e77882a"><img loading="lazy" decoding="async" width="1544" height="396" class="gb-image gb-image-3e77882a" src="https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-1.jpg" alt="" title="state-init-pass-value-1" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-1.jpg 1544w, https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-1-300x77.jpg 300w, https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-1-1024x263.jpg 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-1-768x197.jpg 768w, https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-1-1536x394.jpg 1536w" sizes="auto, (max-width: 1544px) 100vw, 1544px" /></figure>



<div style="height:44px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Why it breaks</h2>



<p class="wp-block-paragraph">Let`s see, how the AttributeGraph looks behind these flow. First the selection is nil and no detail is shown:</p>



<figure class="gb-block-image gb-block-image-737fdeb1"><img loading="lazy" decoding="async" width="818" height="402" class="gb-image gb-image-737fdeb1" src="https://www.swiftyplace.com/wp-content/uploads/2026/09/Screenshot-2026-09-22-at-12.23.28-PM.png" alt="" title="Screenshot 2026-09-22 at 12.23.28 PM" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/09/Screenshot-2026-09-22-at-12.23.28-PM.png 818w, https://www.swiftyplace.com/wp-content/uploads/2026/09/Screenshot-2026-09-22-at-12.23.28-PM-300x147.png 300w, https://www.swiftyplace.com/wp-content/uploads/2026/09/Screenshot-2026-09-22-at-12.23.28-PM-768x377.png 768w" sizes="auto, (max-width: 818px) 100vw, 818px" /></figure>



<p class="wp-block-paragraph">Next selecting an item from the sidebar. The AttributeGraph adds a node for the new DetailView and stores the asscoated state with it:</p>



<figure class="gb-block-image gb-block-image-f92acc2c"><img loading="lazy" decoding="async" width="885" height="418" class="gb-image gb-image-f92acc2c" src="https://www.swiftyplace.com/wp-content/uploads/2026/09/Screenshot-2026-09-22-at-12.26.06-PM.png" alt="" title="Screenshot 2026-09-22 at 12.26.06 PM" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/09/Screenshot-2026-09-22-at-12.26.06-PM.png 885w, https://www.swiftyplace.com/wp-content/uploads/2026/09/Screenshot-2026-09-22-at-12.26.06-PM-300x142.png 300w, https://www.swiftyplace.com/wp-content/uploads/2026/09/Screenshot-2026-09-22-at-12.26.06-PM-768x363.png 768w" sizes="auto, (max-width: 885px) 100vw, 885px" /></figure>



<p class="wp-block-paragraph"><code>@State</code> creates a node in the AttributeGraph. SwiftUI reads the underscore syntax once. When that view identity is born. After that, the storage lives in the graph.</p>



<p class="wp-block-paragraph">When the parent passes a new <code>userID</code>, <code>init</code> runs again. <code>_viewModel = State(wrappedValue: ViewModel(id: newUserID))</code> executes. And SwiftUI throws the new value away. It already has a node for this identity. It doesn&#8217;t need a new one.</p>



<p class="wp-block-paragraph"><strong>You now have two sources of truth.</strong> The parent knows the selection changed. The child&#8217;s view model is still working with the old <code>userID</code>. They&#8217;re out of sync.</p>



<div style="height:47px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">The fix</h2>



<p class="wp-block-paragraph">Stop trying to re-initialize. React to the change instead. Pass <code>userID</code> as a plain property and let the view model own the response:</p>



<div class="wp-block-kevinbatdorf-code-block-pro cbp-has-line-numbers" data-code-block-pro-font-family="Code-Pro-JetBrains-Mono-NL.ttf" style="font-size:.875rem;font-family:Code-Pro-JetBrains-Mono-NL,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--cbp-line-number-color:#f6f6f4;--cbp-line-number-width:calc(2 * 0.6 * .875rem);line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)"><span role="button" tabindex="0" style="color:#f6f6f4;display:none" aria-label="Copy" class="code-block-pro-copy-button"><pre class="code-block-pro-copy-button-pre" aria-hidden="true"><textarea class="code-block-pro-copy-button-textarea" tabindex="-1" aria-hidden="true" readonly>struct DetailView: View {
    let userID: UUID
    @State private var viewModel: ViewModel?

    var body: some View {
        UserDetailContent(viewModel: viewModel)
            .task(id: userID) {
                if viewModel == nil {
                    viewModel = ViewModel()
                }
                await viewModel?.load(for: userID)
            }
    }
}</textarea></pre><svg xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path class="with-check" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"></path><path class="without-check" stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6"></path></svg></span><pre class="shiki dracula-soft" style="background-color: #282A36" tabindex="0"><code><span class="line"><span style="color: #F286C4">struct</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1; font-style: italic">DetailView</span><span style="color: #F6F6F4">: </span><span style="color: #97E1F1; font-style: italic">View </span><span style="color: #F6F6F4">{</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">let</span><span style="color: #F6F6F4"> userID: UUID</span></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">@State</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">private</span><span style="color: #F6F6F4"> </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> viewModel: ViewModel</span><span style="color: #F286C4">?</span></span>
<span class="line"></span>
<span class="line"><span style="color: #F6F6F4">    </span><span style="color: #F286C4">var</span><span style="color: #F6F6F4"> body: </span><span style="color: #F286C4">some</span><span style="color: #F6F6F4"> View {</span></span>
<span class="line"><span style="color: #F6F6F4">        </span><span style="color: #97E1F1">UserDetailContent</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">viewModel</span><span style="color: #F6F6F4">: viewModel)</span></span>
<span class="line"><span style="color: #F6F6F4">            .</span><span style="color: #97E1F1">task</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">id</span><span style="color: #F6F6F4">: userID) {</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">if</span><span style="color: #F6F6F4"> viewModel </span><span style="color: #F286C4">==</span><span style="color: #F6F6F4"> </span><span style="color: #BF9EEE">nil</span><span style="color: #F6F6F4"> {</span></span>
<span class="line"><span style="color: #F6F6F4">                    viewModel </span><span style="color: #F286C4">=</span><span style="color: #F6F6F4"> </span><span style="color: #97E1F1">ViewModel</span><span style="color: #F6F6F4">()</span></span>
<span class="line"><span style="color: #F6F6F4">                }</span></span>
<span class="line"><span style="color: #F6F6F4">                </span><span style="color: #F286C4">await</span><span style="color: #F6F6F4"> viewModel</span><span style="color: #F286C4">?</span><span style="color: #F6F6F4">.</span><span style="color: #97E1F1">load</span><span style="color: #F6F6F4">(</span><span style="color: #97E1F1">for</span><span style="color: #F6F6F4">: userID)</span></span>
<span class="line"><span style="color: #F6F6F4">            }</span></span>
<span class="line"><span style="color: #F6F6F4">    }</span></span>
<span class="line"><span style="color: #F6F6F4">}</span></span></code></pre></div>



<div style="height:31px" aria-hidden="true" class="wp-block-spacer"></div>



<p class="wp-block-paragraph"><code>userID</code> is a plain property. An input edge in the graph. <code>@State</code> holds the view model as a node. <code>.task(id:)</code> fires whenever <code>userID</code> changes, including the first time the view appears. The view model is created once, and <code>load(for:)</code> handles every subsequent change.</p>



<p class="wp-block-paragraph">The parent owns the selected ID. The view model owns the loaded data. One source of truth on each side.</p>



<figure class="gb-block-image gb-block-image-37c02728"><img loading="lazy" decoding="async" width="1544" height="396" class="gb-image gb-image-37c02728" src="https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-2.jpg" alt="" title="state-init-pass-value-2" srcset="https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-2.jpg 1544w, https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-2-300x77.jpg 300w, https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-2-1024x263.jpg 1024w, https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-2-768x197.jpg 768w, https://www.swiftyplace.com/wp-content/uploads/2026/09/state-init-pass-value-2-1536x394.jpg 1536w" sizes="auto, (max-width: 1544px) 100vw, 1544px" /></figure>



<div style="height:100px" aria-hidden="true" class="wp-block-spacer"></div>



<h2 class="wp-block-heading">Going deeper</h2>



<p class="wp-block-paragraph">This example is from chapter 3 of <a href="https://school.swiftyplace.com/courses/swiftui-handbook-data-flow?coupon=SEPTEMBERRESET" data-type="link" data-id="https://school.swiftyplace.com/courses/swiftui-handbook-data-flow?coupon=SEPTEMBERRESET" target="_blank" rel="noopener">my book, SwiftUI Data Flow.</a> The whole book is built around understanding how the AttributeGraph, view identity, and state ownership actually work. Ao you can reason about your SwiftUI code instead of guessing.</p>



<p class="wp-block-paragraph">30% off until the end of September.</p>



<p class="wp-block-paragraph">Read about the <a href="https://www.swiftyplace.com/blog/the-attributegraph-the-engine-behind-every-swiftui-view" data-type="link" data-id="https://www.swiftyplace.com/blog/the-attributegraph-the-engine-behind-every-swiftui-view">AttributeGraph in this post</a>. </p>
<p>The post <a rel="nofollow" href="https://www.swiftyplace.com/blog/swiftui-state-property-wrapper">What is @State actually doing in SwiftUI</a> appeared first on <a rel="nofollow" href="https://www.swiftyplace.com">swiftyplace</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.swiftyplace.com/blog/swiftui-state-property-wrapper/feed</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: www.swiftyplace.com @ 2026-11-14 23:38:02 by W3 Total Cache
-->