How I Refactor a Messy SwiftUI View Without Breaking Everything
How I Refactor a Messy SwiftUI View Without Breaking Everything
How I Refactor a Messy SwiftUI View Without Breaking Everything
How I Refactor a Messy SwiftUI View Without Breaking Everything
A practical, production-minded approach to cleaning up large SwiftUI views while keeping behavior stable, reviews manageable, and regressions under control.

SwiftUI views usually start simple.
struct ProductView: View {
var body: some View {
Text("Product")
}
}
Then the feature grows.
You add loading states. Error handling. Analytics. Navigation. Sheets. Alerts. Product variants. Add-to-cart logic. Feature flags. Accessibility. Experiments.
A few months later, that innocent ProductView has become a 700-line file that nobody wants to touch.
And then someone says:
“Can we clean this up?”
Of course we can.
The harder question is:
Can we clean it up without breaking production behavior?
After working on larger SwiftUI codebases, I’ve learned that refactoring isn’t about making the code beautiful as quickly as possible.
It’s about reducing complexity while preserving behavior.
Here’s the process I use.
The Messy SwiftUI View
Imagine we have something like this:
struct ProductDetailView: View {
@StateObject private var viewModel: ProductDetailViewModel
@State private var selectedSize: Size?
@State private var selectedColor: ColorOption?
@State private var showSizeGuide = false
@State private var showError = false
@State private var showCart = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
if viewModel.isLoading {
ProgressView()
} else if let product = viewModel.product {
ProductImageCarousel(images: product.images)
VStack(alignment: .leading) {
Text(product.name)
.font(.title)
Text(product.price)
.font(.headline)
if product.isOnSale {
Text("SALE")
.font(.caption)
}
// another 100+ lines...
ForEach(product.colors) { color in
// color selection logic
}
ForEach(product.sizes) { size in
// size selection logic
}
Button("Add to Cart") {
// validation
// analytics
// API call
// state updates
}
}
}
}
}
.sheet(isPresented: $showSizeGuide) {
SizeGuideView()
}
.alert("Something went wrong", isPresented: $showError) {
Button("OK") {}
}
.task {
await viewModel.loadProduct()
}
}
}
Technically, this works.
But the view is doing too many things.
It’s rendering UI, managing presentation state, coordinating business logic, triggering analytics, validating selections, and deciding navigation behavior.
That’s where refactoring becomes necessary.
Rule #1: Don’t Start by Rewriting Everything
This is the biggest mistake I see during refactoring.
A developer opens a messy file and thinks:
“I’ll rebuild this properly.”
Suddenly one pull request changes:
- the view hierarchy
- state management
- the ViewModel
- navigation
- networking
- component structure
- naming
- business logic
Now the PR contains 1,500 changed lines.
Even if the new architecture is better, reviewing it becomes extremely difficult.
And when QA discovers something broken, figuring out which change caused it becomes painful.
Instead, I refactor incrementally.
My first goal isn’t:
Make the architecture perfect.
It’s:
Make the next change safer than the previous one.
Step 1: Understand the Existing Behavior
Before moving a single line of code, I identify what the screen currently does.
For a product screen, that might include:
- loading the product
- displaying images
- showing pricing
- selecting colors
- selecting sizes
- handling unavailable variants
- adding items to cart
- showing errors
- opening a size guide
- tracking analytics
- navigating to another screen
This becomes my mental regression checklist.
If the feature is particularly risky, I’ll also record the current screen or take screenshots of important states.
Refactoring is much safer when you know exactly what behavior you’re trying to preserve.
Step 2: Identify Visual Boundaries
I don’t immediately create new ViewModels, protocols, repositories, coordinators, and services.
I start with the safest refactor:
Extract obvious UI sections.
For example, this:
VStack(alignment: .leading) {
Text(product.name)
.font(.title)
Text(product.price)
.font(.headline)
if product.isOnSale {
Text("SALE")
.font(.caption)
}
}
can become:
ProductHeaderView(product: product)
with:
struct ProductHeaderView: View {
let product: Product
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(product.name)
.font(.title)
Text(product.price)
.font(.headline)
if product.isOnSale {
Text("SALE")
.font(.caption)
}
}
}
}
This is a relatively low-risk change.
The behavior hasn’t changed.
We’ve simply created a clearer boundary.
Step 3: Make the Parent View Read Like a Screen Outline
One of my goals is to make the main body easy to scan.
Instead of this:
var body: some View {
ScrollView {
VStack {
// 500 lines
}
}
}
I want something closer to:
var body: some View {
ScrollView {
VStack(spacing: 24) {
productGallery
productInformation
colorSelector
sizeSelector
purchaseSection
}
}
}
Or with separate views:
var body: some View {
ScrollView {
VStack(spacing: 24) {
ProductGalleryView(product: product)
ProductInformationView(product: product)
ColorSelectorView(...)
SizeSelectorView(...)
PurchaseSectionView(...)
}
}
}
Now I can understand the structure of the screen without reading hundreds of lines.
That’s a major improvement by itself.
Step 4: Don’t Extract Everything Into a Separate View
There is another extreme.
Some developers turn every five lines into another View.
You end up with:
ProductTitleView
ProductPriceView
ProductSaleBadgeView
ProductDescriptionView
ProductDividerView
ProductButtonContainerView
Now understanding one screen requires opening 15 files.
That’s not necessarily better.
I usually extract something when it has at least one of these characteristics:
- meaningful visual responsibility
- complex internal layout
- reusable behavior
- independent state
- significant conditional rendering
- enough code to distract from the parent view
The goal isn’t the maximum number of components.
The goal is clear responsibility boundaries.
Step 5: Separate UI State From Business State
This is where messy SwiftUI views often become difficult.
Consider:
@State private var isLoading = false
@State private var product: Product?
@State private var error: Error?
@State private var selectedSize: Size?
@State private var selectedColor: ColorOption?
@State private var isAddingToCart = false
Some of this belongs to the screen.
Some of it belongs to the feature’s business logic.
For example:
@State private var showSizeGuide = false
is presentation state.
But:
@State private var product: Product?
@State private var isLoading = false
@State private var error: Error?
may be better owned by a ViewModel or another state-management layer.
I might move toward:
@MainActor
final class ProductDetailViewModel: ObservableObject {
@Published private(set) var product: Product?
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
func loadProduct() async {
// ...
}
}
while the view continues to own purely visual state:
@State private var showSizeGuide = false
@State private var showCart = false
That distinction makes the screen much easier to reason about.
Step 6: Move Actions Out of the View Body
A giant warning sign for me is a button containing a lot of logic:
Button("Add to Cart") {
guard let size = selectedSize else {
showSizeError = true
return
}
analytics.track(
event: "add_to_cart",
productId: product.id
)
Task {
do {
try await cartService.add(
product: product,
size: size
)
showCart = true
} catch {
showError = true
}
}
}
The view now needs to understand far too much.
At minimum, I would extract the action:
Button("Add to Cart") {
addToCart()
}
Then:
private func addToCart() {
// existing logic
}
That doesn’t solve the architecture yet.
But it makes the UI easier to understand.
Later, business logic can move somewhere more appropriate:
Button("Add to Cart") {
Task {
await viewModel.addToCart()
}
}
The important part is that I don’t necessarily make both changes at once.
Small refactors are easier to verify.
Step 7: Be Careful With SwiftUI State Ownership
This is where a seemingly harmless refactor can change behavior.
Suppose this state originally lives in the parent:
@State private var selectedSize: Size?
Then I extract:
SizeSelectorView()
and accidentally create:
struct SizeSelectorView: View {
@State private var selectedSize: Size?
// ...
}
I’ve changed ownership.
The parent no longer knows which size is selected.
Instead, I might need:
struct SizeSelectorView: View {
@Binding var selectedSize: Size?
// ...
}
and:
SizeSelectorView(
selectedSize: $selectedSize
)
This is why extracting SwiftUI views isn’t always a purely visual refactor.
You need to understand who owns the state.
My general rule:
Move the UI first. Move state ownership deliberately.
Never move both accidentally.
Step 8: Watch for Lifecycle Changes
SwiftUI lifecycle behavior can make refactoring surprisingly dangerous.
Moving something like:
.task {
await viewModel.loadProduct()
}
to a child view might cause it to execute at a different time or more often than expected.
The same applies to:
.onAppear
.onDisappear
.onChange
.task
.sheet
.navigationDestination
When extracting views, I pay special attention to these modifiers.
Their location isn’t always just organizational.
It can affect behavior.
Step 9: Keep Dependencies Explicit
Messy views sometimes access everything globally:
Analytics.shared
CartManager.shared
UserSession.shared
FeatureFlags.shared
This makes extraction harder because every component secretly depends on half the application.
Instead, I prefer dependencies to become visible.
For example:
struct ProductDetailView: View {
let analytics: AnalyticsTracking
let cartService: CartServicing
// ...
}
Or dependencies can be owned by the ViewModel.
This has another benefit:
Testing becomes much easier.
Step 10: Add Tests Around Risky Logic
I don’t necessarily test every extracted VStack.
But if refactoring touches important logic, I want tests around it.
For example:
func testAddToCartRequiresSelectedSize() async {
// ...
}
func testSuccessfulAddToCartUpdatesState() async {
// ...
}
func testLoadProductDisplaysErrorWhenRequestFails() async {
// ...
}
The more business logic you can test without rendering SwiftUI, the safer future refactoring becomes.
Step 11: Compile Constantly
I don’t refactor 500 lines and then press Cmd + B.
My workflow is closer to:
Extract component
↓
Build
↓
Run
↓
Verify
↓
Commit
↓
Extract next component
Swift’s compiler is extremely useful during refactoring.
Let it help you.
Small compilation cycles expose problems before multiple changes become tangled together.
Step 12: Keep Commits Small
Instead of:
Refactor ProductDetailView
containing 40 changes, I prefer commits such as:
Extract product header view
Extract size selector
Move add-to-cart action to ViewModel
Extract product gallery
Add tests for product selection
If something breaks, git bisect, code review, and rollback all become easier.
Small commits aren’t just cleaner Git history.
They’re a risk-management strategy.
What the Final View Might Look Like
After several safe refactors, our screen might become:
struct ProductDetailView: View {
@StateObject private var viewModel: ProductDetailViewModel
@State private var showSizeGuide = false
var body: some View {
content
.task {
await viewModel.loadProduct()
}
.sheet(isPresented: $showSizeGuide) {
SizeGuideView()
}
}
@ViewBuilder
private var content: some View {
if viewModel.isLoading {
ProgressView()
} else if let product = viewModel.product {
productContent(product)
} else {
ErrorView()
}
}
private func productContent(
_ product: Product
) -> some View {
ScrollView {
VStack(spacing: 24) {
ProductGalleryView(
images: product.images
)
ProductHeaderView(
product: product
)
ColorSelectorView(
colors: product.colors,
selection: $viewModel.selectedColor
)
SizeSelectorView(
sizes: product.sizes,
selection: $viewModel.selectedSize
)
PurchaseSectionView(
isLoading: viewModel.isAddingToCart,
onAddToCart: {
Task {
await viewModel.addToCart()
}
}
)
}
}
}
}
Compare this with the original giant view.
The screen now tells a story:
Load product
↓
Show gallery
↓
Show information
↓
Select color
↓
Select size
↓
Purchase
That’s what I want from a SwiftUI view.
My Refactoring Order
When dealing with a particularly messy production view, I usually follow this order:
- Understand existing behavior
- Identify risky lifecycle and state logic
- Extract obvious visual sections
- Simplify the parent
body - Extract large actions into methods
- Clarify state ownership
- Move business logic out of the view
- Make dependencies explicit
- Add or improve tests
- Clean up naming and duplication
Notice that architectural changes come relatively late.
That’s intentional.
What I Avoid During Refactoring
There are several things I try not to combine in the same refactor.
Refactoring + New Features
If possible, I don’t add a new feature while completely restructuring the existing screen.
Otherwise, when something breaks, it’s harder to know whether the feature or refactor caused it.
Changing Architecture Everywhere
One messy SwiftUI screen doesn’t necessarily justify migrating the entire application to a new architecture.
Solve the problem you actually have.
Premature Generic Components
I don’t immediately turn this:
ProductHeaderView
into:
GenericConfigurableCommerceHeader<
Content,
Metadata,
Action
>
because maybe we’ll reuse it someday.
I’ll generalize components when actual reuse appears.
Refactoring Just for Fewer Lines
A 100-line view isn’t automatically better than a 300-line view.
I care more about:
- responsibilities
- state ownership
- readability
- testability
- predictability
Line count is only a symptom.
A Useful Question I Ask Myself
Whenever I’m about to refactor something, I ask:
Am I changing the structure, or am I changing the behavior?
Ideally, I don’t do both at the same time.
First:
Same behavior
Better structure
Then, in another change:
Better behavior
Using the cleaner structure
This separation makes production refactoring dramatically safer.
The Real Goal Isn’t Smaller Views
It’s tempting to measure a successful SwiftUI refactor like this:
Before: 847 lines
After: 163 lines
But that’s not really the goal.
A better measurement is:
Can another developer understand what this screen does without being afraid to change it?
Good SwiftUI architecture should make changes boring.
You should be able to open a feature, understand where state lives, understand where business logic happens, modify one piece, run the tests, and ship.
That’s much more valuable than having the most elegant architecture diagram.
Final Thoughts
Messy SwiftUI views are almost inevitable in production applications.
Features evolve.
Requirements change.
Experiments get added.
Deadlines happen.
The answer isn’t to prevent every view from becoming messy.
The important skill is knowing how to safely recover when it does.
My approach is simple:
Preserve behavior. Reduce responsibilities. Make small changes. Verify constantly.
Don’t turn a 700-line SwiftUI view into a completely new architecture in one pull request.
Extract one responsibility.
Build.
Test.
Commit.
Then repeat.
Because the best production refactor isn’t the one with the cleverest architecture.
It’s the one where users never realize you refactored anything.
메타데이터
- post_id
- bc7c14830d9f
- slug
- how-i-refactor-a-messy-swiftui-view-without-breaking-everything-bc7c14830d9f
- url
- https://medium.com/@khatzie/how-i-refactor-a-messy-swiftui-view-without-breaking-everything-bc7c14830d9f
- canonical_url
- https://medium.com/@khatzie/how-i-refactor-a-messy-swiftui-view-without-breaking-everything-bc7c14830d9f
- author_url
- https://medium.com/@khatzie
- status
- ok
- fetched_at
- 2026-09-06 16:10:56