@State Deep Dive in SwiftUI: Understanding Local State Management
Part 31 of 100 in the SwiftUI Zero to Expert Series
My first SwiftUI bug report came from a junior developer: “The counter keeps resetting to zero.” I looked at their code and saw it immediately — they’d declared var count = 0 instead of @State private var count = 0.
A single missing property wrapper, and everything broke.
That’s when I realized @State isn't just syntax.
It’s a fundamental shift in how we think about data.
In UIKit, we mutated properties freely and called reloadData() or setNeedsDisplay() when we wanted the UI to update.
In SwiftUI, the view is a function of state — change the state, and the UI updates automatically.
But automatic doesn’t mean magic.
Understanding how @State works—what it preserves, when it resets, why it needs to be private—makes the difference between fighting the framework and flowing with it.
What is @State?
@State is a property wrapper that creates a source of truth for value types within a view.
When the state changes, SwiftUI automatically re-renders the view.
Basic Usage
import SwiftUI
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack(spacing: 20) {
Text("Count: \(count)")
.font(.largeTitle)
HStack(spacing: 20) {
Button("Decrement") {
count -= 1
}
Button("Increment") {
count += 1
}
}
.buttonStyle(.borderedProminent)
}
}
}Every tap triggers a state change.
Every state change triggers a view re-evaluation.
The connection is automatic.
Why @State Exists
Without @State, SwiftUI views are structs—value types that get recreated frequently.
State allows values to persist across view recreations.
import SwiftUI
// This WON'T work - count resets on every view update
struct BrokenCounter: View {
var count = 0 // Not @State
var body: some View {
Button("Count: \(count)") {
// count += 1 // Error: Cannot mutate
}
}
}
// This WORKS - state persists
struct WorkingCounter: View {
@State private var count = 0
var body: some View {
Button("Count: \(count)") {
count += 1
}
}
}The struct is recreated constantly, but the storage behind @State persists.
SwiftUI maintains that storage for the lifetime of the view’s identity.
@State Rules and Best Practices
Rule 1: Always Mark as Private
import SwiftUI
// ✅ Correct - private prevents external mutation
struct GoodView: View {
@State private var isExpanded = false
// ...
}
// ⚠️ Avoid - exposes implementation detail
struct RiskyView: View {
@State var isExpanded = false // Not private
// ...
}If a parent should modify this value, they should own it and pass a @Binding.
A parent setting a child’s @State directly causes confusing behavior—the initialization happens only once, so subsequent parent updates are ignored.
Rule 2: Initialize at Declaration
import SwiftUI
// ✅ Correct - initialized at declaration
struct GoodView: View {
@State private var text = ""
@State private var count = 0
@State private var items: [String] = []
// ...
}
// ⚠️ Avoid - initializing in init (has edge cases)
struct ProblematicView: View {
@State private var text: String
init(initialText: String) {
_text = State(initialValue: initialText)
}
// ...
}Initializing in init works for the first render, but if the parent passes a new initialText, it won't update.
The state was already created.
This trips up many developers.
Rule 3: Use for View-Local State Only
import SwiftUI
// ✅ Correct - UI state local to this view
struct ToggleView: View {
@State private var isOn = false
@State private var showingSheet = false
@State private var selectedTab = 0
// ...
}
// ⚠️ Wrong - should use @StateObject for reference types
struct WrongView: View {
@State private var viewModel = ViewModel() // Wrong!
// Use @StateObject instead
}@State is for value types—booleans, numbers, strings, structs.
For classes (reference types), use @StateObject.
Common @State Patterns
Toggle States
import SwiftUI
struct TogglePatterns: View {
@State private var isEnabled = false
@State private var showAdvanced = false
@State private var agreedToTerms = false
var body: some View {
Form {
Toggle("Enable Feature", isOn: $isEnabled)
Toggle("Show Advanced Options", isOn: $showAdvanced)
if showAdvanced {
// Advanced options
Text("Advanced options appear here")
}
Toggle("I agree to terms", isOn: $agreedToTerms)
Button("Continue") {
// Action
}
.disabled(!agreedToTerms)
}
}
}The conditional rendering (if showAdvanced) is the power of state—the UI restructures itself based on current values.
Text Input State
import SwiftUI
struct TextInputPatterns: View {
@State private var username = ""
@State private var password = ""
@State private var bio = ""
var isValid: Bool {
!username.isEmpty && password.count >= 8
}
var body: some View {
Form {
Section("Credentials") {
TextField("Username", text: $username)
.textContentType(.username)
.autocapitalization(.none)
SecureField("Password", text: $password)
.textContentType(.password)
}
Section("Profile") {
TextEditor(text: $bio)
.frame(height: 100)
Text("\(bio.count)/500 characters")
.font(.caption)
.foregroundColor(bio.count > 500 ? .red : .secondary)
}
Button("Save") {
saveProfile()
}
.disabled(!isValid)
}
}
func saveProfile() {
print("Saving: \(username)")
}
}The computed property isValid derives from state without being state itself.
This is idiomatic SwiftUI — derive what you can, store only what you must.
Selection State
import SwiftUI
struct SelectionPatterns: View {
@State private var selectedFruit = "Apple"
@State private var selectedColor = Color.blue
@State private var selectedDate = Date()
@State private var sliderValue = 50.0
let fruits = ["Apple", "Banana", "Orange", "Grape"]
var body: some View {
Form {
Picker("Fruit", selection: $selectedFruit) {
ForEach(fruits, id: \.self) { fruit in
Text(fruit).tag(fruit)
}
}
ColorPicker("Theme Color", selection: $selectedColor)
DatePicker("Date", selection: $selectedDate, displayedComponents: .date)
VStack {
Slider(value: $sliderValue, in: 0...100)
Text("Value: \(Int(sliderValue))")
}
}
}
}All these controls need two-way binding ($), but the source of truth is @State in this view.
Presentation State
import SwiftUI
struct PresentationPatterns: View {
@State private var showSheet = false
@State private var showFullScreen = false
@State private var showAlert = false
@State private var showConfirmation = false
var body: some View {
VStack(spacing: 20) {
Button("Show Sheet") {
showSheet = true
}
Button("Show Full Screen") {
showFullScreen = true
}
Button("Show Alert") {
showAlert = true
}
Button("Show Confirmation") {
showConfirmation = true
}
}
.sheet(isPresented: $showSheet) {
Text("Sheet Content")
}
.fullScreenCover(isPresented: $showFullScreen) {
VStack {
Text("Full Screen")
Button("Dismiss") {
showFullScreen = false
}
}
}
.alert("Alert Title", isPresented: $showAlert) {
Button("OK", role: .cancel) { }
}
.confirmationDialog("Choose Action", isPresented: $showConfirmation) {
Button("Option 1") { }
Button("Option 2") { }
Button("Cancel", role: .cancel) { }
}
}
}Each presentation has its own Boolean state.
This explicit modeling of “is X showing?” is clearer than UIKit’s implicit presentation stacks.
Collection State
import SwiftUI
struct CollectionPatterns: View {
@State private var items = ["Item 1", "Item 2", "Item 3"]
@State private var selectedItems: Set<String> = []
@State private var newItemText = ""
var body: some View {
NavigationStack {
List {
ForEach(items, id: \.self) { item in
HStack {
Text(item)
Spacer()
if selectedItems.contains(item) {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
.onTapGesture {
toggleSelection(item)
}
}
.onDelete(perform: deleteItems)
.onMove(perform: moveItems)
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
EditButton()
}
ToolbarItem(placement: .topBarTrailing) {
Button("Add") {
addItem()
}
}
}
}
}
func toggleSelection(_ item: String) {
if selectedItems.contains(item) {
selectedItems.remove(item)
} else {
selectedItems.insert(item)
}
}
func deleteItems(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
}
func moveItems(from source: IndexSet, to destination: Int) {
items.move(fromOffsets: source, toOffset: destination)
}
func addItem() {
let newItem = "Item \(items.count + 1)"
items.append(newItem)
}
}Arrays and Sets work with @State because they're value types.
When you append or remove, Swift creates a new collection and SwiftUI detects the change.
State with Animations
import SwiftUI
struct AnimatedStateView: View {
@State private var isExpanded = false
@State private var rotation = 0.0
@State private var scale = 1.0
var body: some View {
VStack(spacing: 30) {
// Implicit animation
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: isExpanded ? 200 : 100, height: 100)
.animation(.spring(), value: isExpanded)
.onTapGesture {
isExpanded.toggle()
}
// Explicit animation
Button("Rotate") {
withAnimation(.easeInOut(duration: 0.5)) {
rotation += 90
}
}
Rectangle()
.fill(Color.green)
.frame(width: 100, height: 100)
.rotationEffect(.degrees(rotation))
// Combine animations
Circle()
.fill(Color.purple)
.frame(width: 100, height: 100)
.scaleEffect(scale)
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
scale = scale == 1.0 ? 1.5 : 1.0
}
}
}
}
}Animation is state change over time.
By animating state changes, you get fluid transitions without managing animation controllers.
Debugging State Changes
import SwiftUI
struct DebugStateView: View {
@State private var count = 0
var body: some View {
let _ = Self._printChanges() // Debug helper
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1
}
}
}
}
// Custom onChange debugging
extension View {
func debugState<V: Equatable>(_ value: V, label: String) -> some View {
self.onChange(of: value) { newValue in
print("[\(label)] changed to: \(newValue)")
}
}
}Self._printChanges() is invaluable for debugging unexpected re-renders.
It prints what changed to trigger this body evaluation.
Common Mistakes
Mistake 1: Mutating State in Body
import SwiftUI
// ❌ Wrong - causes infinite loop
struct BadView: View {
@State private var count = 0
var body: some View {
count += 1 // Never do this!
return Text("Count: \(count)")
}
}
// ✅ Correct - mutate in callbacks
struct GoodView: View {
@State private var count = 0
var body: some View {
Text("Count: \(count)")
.onAppear {
count += 1 // OK in lifecycle methods
}
}
}Body is called to compute the view tree.
Mutating state during that computation triggers another computation.
Infinite loop.
Mistake 2: Using @State for Reference Types
import SwiftUI
// ❌ Wrong - use @StateObject
struct BadView: View {
@State private var viewModel = MyViewModel()
// Changes to viewModel properties won't trigger updates
}
// ✅ Correct
struct GoodView: View {
@StateObject private var viewModel = MyViewModel()
}@State watches the reference itself, not the object's properties.
For observing class property changes, use @StateObject.
Mistake 3: Deriving State from Props
import SwiftUI
// ❌ Wrong - state won't update when props change
struct BadView: View {
let initialValue: Int
@State private var value: Int
init(initialValue: Int) {
self.initialValue = initialValue
_value = State(initialValue: initialValue)
}
// value won't update if parent passes new initialValue
}
// ✅ Correct - use binding or computed property
struct GoodView: View {
@Binding var value: Int
// or compute from passed value
}This is the most common @State bug.
Initial values are only used once.
If the parent should control this value, pass a @Binding.
What I Know Now That I Wish I’d Known Then
State is identity. SwiftUI tracks views by their position in the hierarchy. Same position, same state. Change the position (or add an explicit .id()), and state resets.
Derived values shouldn’t be state. If you can compute it from other state, make it a computed property. Duplicate state leads to inconsistencies.
State initialization happens once. The initial value is captured at view creation. Subsequent parent updates don’t re-initialize state.
Fewer state variables means fewer bugs. Every piece of state is something that can get out of sync. Store the minimum, derive the rest.
The Gotchas That Caught Me
List row state. State inside a ForEach is tricky. If the data changes order, the state doesn't move with it unless you use .id() properly.
State in previews. Previews create new view instances, so state starts fresh. Use .onAppear to set up preview-specific state.
Animation and state timing. If you change state and immediately read it, you get the new value — but the animation is still in progress. This can cause confusion.
State during navigation. When you navigate away and back, state may or may not persist depending on how the navigation stack manages view lifecycle.
@State is foundational to SwiftUI development:
- Simple values like booleans, numbers, strings
- View-local UI state only
- Private to prevent external mutation
- Automatic view updates on change
That junior developer’s bug the missing @Statebecame a teaching moment.
We now have a code review checklist item: “Is mutable view data wrapped in @State?”
Understanding @State deeply not just what it does, but why it exists and how it works is the foundation for all SwiftUI state management.
In the next article, we’ll explore @Binding for two-way data flow.
Thank you for reading! If you enjoyed it, please consider clapping 👏 and following for more updates! 🚀
