How do you design reusable SwiftUI components?
“Reusable” in SwiftUI specifically means designing around its styling/configuration conventions rather than just extracting a view into its own file.
1. Configure via parameters, not hardcoded content — the baseline
struct PrimaryButton: View {
let title: String
let icon: String?
let action: () -> Void
init(_ title: String, icon: String? = nil, action: @escaping () -> Void) {
self.title = title
self.icon = icon
self.action = action
}
var body: some View {
Button(action: action) {
HStack {
if let icon { Image(systemName: icon) }
Text(title)
}
}
.buttonStyle(.borderedProminent)
}
}
Sensible defaults for optional parameters keep the common case simple while allowing customization.
2. Use ViewBuilder for structural composition, not just data parameters
struct Card<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 8) { content }
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
Card doesn’t care what’s inside it, only that it provides consistent chrome around arbitrary content.
3. Prefer view modifiers over parameter explosion for optional behavior
struct BadgeModifier: ViewModifier {
let count: Int
func body(content: Content) -> some View {
content.overlay(alignment: .topTrailing) {
if count > 0 {
Text("\(count)")
.font(.caption2)
.padding(4)
.background(.red, in: Circle())
}
}
}
}
extension View {
func badge(count: Int) -> some View { modifier(BadgeModifier(count: count)) }
}
This scales better than a component anticipating every combination of optional decorations up front.
4. Custom ButtonStyle/LabelStyle/ToggleStyle for consistent interactive behavior
struct PillButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.tint, in: Capsule())
.opacity(configuration.isPressed ? 0.7 : 1.0)
}
}
This applies to any button anywhere, decoupling “how it looks” from “what it does” — more reusable than a bespoke wrapper view.
5. Design system tokens instead of hardcoded values
enum DesignTokens {
enum Spacing { static let small: CGFloat = 8; static let medium: CGFloat = 16 }
enum Colors { static let primaryAction = Color("PrimaryAction") }
}
Changing the design system updates every component consistently — ties back to the shared-module discussion, a DesignSystem module multiple feature teams depend on.
6. Respect the environment rather than fighting it
struct StatCard: View {
@Environment(\.dynamicTypeSize) private var typeSize
let value: String
let label: String
var body: some View {
if typeSize.isAccessibilitySize {
VStack { Text(value); Text(label) }
} else {
HStack { Text(value); Text(label) }
}
}
}
A component that hardcodes layout assumptions breaks the moment it’s reused somewhere with different constraints.
7. Keep state ownership external when the component is meant to be controlled, internal when it’s self-contained
// Caller controls state
struct SearchField: View {
@Binding var text: String
var body: some View { TextField("Search", text: $text) }
}
// Component owns its own state
struct ExpandableSection: View {
@State private var isExpanded = false
}
Getting this wrong in either direction hurts reusability.
8. Accessibility as a built-in property of the component, not an afterthought per call site
struct IconButton: View {
let systemName: String
let accessibilityLabel: String // required, not optional/forgettable
let action: () -> Void
var body: some View {
Button(action: action) { Image(systemName: systemName) }
.accessibilityLabel(accessibilityLabel)
}
}
Making it a required parameter prevents an entire class of accessibility regressions.
9. Preview coverage for every meaningful variant
#Preview("Default") { PrimaryButton("Continue") {} }
#Preview("Dark Mode") { PrimaryButton("Continue") {}.preferredColorScheme(.dark) }
#Preview("Accessibility Size") { PrimaryButton("Continue") {}.dynamicTypeSize(.accessibility3) }
10. Where reusability stops being worth it — the judgment call
Don’t extract a component prematurely. A view used exactly once doesn’t need ViewBuilder flexibility or design-token abstraction. Extract once you have (or clearly anticipate) multiple real consumers with varying needs.
Interview-ready summary
| Technique | What it buys |
|---|---|
| Parameters with sensible defaults | Simple common case, customizable when needed |
@ViewBuilder content closures | Structural flexibility beyond fixed data fields |
Custom ViewModifiers | Composable optional behavior without parameter explosion |
Custom ButtonStyle/LabelStyle | Reuse across any button/label, not just a bespoke wrapper |
| Design tokens | Consistent, centrally-updatable styling |
Respecting @Environment | Correctness across dark mode, dynamic type, layout direction |
| Deliberate state ownership | Right integration ergonomics per use case |
| Required accessibility parameters | Prevents accessibility regressions by construction |
| Preview coverage of variants | Catches breakage in the component, not at each call site |
One-liner if pressed: “I’d design around SwiftUI’s own composition primitives rather than just extracting a view — @ViewBuilder for structural flexibility, custom ButtonStyle/ViewModifiers for composable optional behavior instead of parameter explosion, design tokens instead of hardcoded values, and explicit attention to environment values like dynamic type and dark mode so the component is correct in contexts I didn’t originally write it for — but I’d only extract into this level of reusable component once there’s a real second consumer, not speculatively on first use.”